using System; using System.Collections.Generic; using System.Linq; using Server.Engines.CannedEvil; using Server.Engines.MiniChamps; using Server.Mobiles; namespace Server.Custom.Bridge { /// /// The champion-spawn stream. Like the streams in , this is polled: /// none of the three champion families expose an EventSink, so their whole lifecycle lives /// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them /// each tick, fold each to a small record, and emit `champ.update` only when that record /// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a /// handful of spawns, so the pass is trivially cheap. /// /// Three families, distinguished by the `category` field: /// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown) /// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts) /// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned /// /// Status folds public fields into three values (no core patch needed): /// active - running / alive /// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp: /// inactive, since it always re-arms a restart) /// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on) /// /// The sidecar keeps the latest record per serial as a live board. A permanent controller's /// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it /// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next /// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own. /// public static class BridgeChamps { private static Timer _timer; // Last-emitted signature per tracked serial. A serial absent from this map has never been // emitted (or the cache was cleared on reconnect), so its next sweep counts as a change. // Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families. private static readonly Dictionary _last = new Dictionary(); private static long _sweeps, _emitted, _removed; public static void Initialize() { if (!BridgeConfig.Enabled) return; EventSink.ServerStarted += OnServerStarted; } private static void OnServerStarted() { // Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted // independently of the shard rebuilds its state within one sweep. BridgeLink.Connected_Core += OnConnected; Rearm(); } private static void OnConnected() { _last.Clear(); } /// Stops and recreates the timer from current config. Called by `[bridge reload`. public static void Rearm() { Stop(); _timer = Timer.DelayCall( TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds), TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds), ChampSweep); } public static void Stop() { if (_timer != null) { _timer.Stop(); _timer = null; } } public static string Status() { return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})", _sweeps, _emitted, _removed, _last.Count); } /// Runs one sweep now. Wired into `[bridge sweepnow`. public static void SweepOnce() { ChampSweep(); } private static void ChampSweep() { try { _sweeps++; if (!BridgeLink.Connected) return; // nothing is listening; do not fill the queue with perishable snapshots var seen = new HashSet(); foreach (var s in World.Items.Values.OfType()) { if (s.Deleted) continue; Track(seen, s.Serial, SigChampion(s), WriteChampion(s)); } foreach (var s in World.Items.Values.OfType()) { if (s.Deleted) continue; Track(seen, s.Serial, SigMini(s), WriteMini(s)); } foreach (var b in World.Mobiles.Values.OfType()) { if (b.Deleted || !b.Alive) continue; Track(seen, b.Serial, SigSea(b), WriteSea(b)); } // Anything tracked last sweep but not seen now has gone away (a controller deleted, a // sea boss slain). Tell the sidecar to drop its board row. var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); foreach (var serial in gone) { _last.Remove(serial); BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End()); _removed++; } } catch (Exception ex) { Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message); } } /// Records a spawn as seen and emits it only if its signature changed since last sweep. private static void Track(HashSet seen, Serial serial, string sig, string line) { seen.Add(serial); string prior; if (_last.TryGetValue(serial, out prior) && prior == sig) return; // unchanged since last emit _last[serial] = sig; BridgeLink.Emit(line); _emitted++; } // ---- ChampionSpawn (classic) ---- private static string StatusOf(ChampionSpawn s) { if (s.Active) return "active"; if (s.RestartTime > DateTime.UtcNow) return "cooldown"; return "dormant"; } // The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the // site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition. private static string SigChampion(ChampionSpawn s) { return String.Concat( "champion|", StatusOf(s), "|", s.Level.ToString(), "|", s.Kills.ToString(), "|", (s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|", s.RestartTime.Ticks.ToString(), "|", s.ExpireTime.Ticks.ToString()); } private static string WriteChampion(ChampionSpawn s) { var status = StatusOf(s); var bossUp = s.Champion != null && !s.Champion.Deleted; // Prefer a staff-set display name, then the group, then the spawn type. string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName : !String.IsNullOrEmpty(s.GroupName) ? s.GroupName : s.Type.ToString(); var sb = BridgeJson.Begin("champ.update") .Ser("serial", s.Serial) .Str("category", "champion") .Str("type", s.Type.ToString()) .Str("name", name) .Str("status", status) .Bool("active", s.Active) .Num("level", s.Level) .Num("rank", s.Rank) .Num("kills", s.Kills) .Num("maxKills", s.MaxKills) .Bool("bossUp", bossUp) .Bool("autoRestart", s.AutoRestart) .Str("map", s.Map == null ? null : s.Map.Name) .Num("x", s.X).Num("y", s.Y).Num("z", s.Z); if (bossUp) sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name); // Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown. if (status == "cooldown") sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o")); // Level-expiry ETA: when the current level times out if kills stall. Only while active. if (s.Active) sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o")); return sb.End(); } // ---- MiniChamp (TerMur mini-champs) ---- // MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has // always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no // ETA to report). private static string SigMini(MiniChamp s) { return String.Concat( "mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString()); } private static string WriteMini(MiniChamp s) { var status = s.Active ? "active" : "cooldown"; var info = MiniChampInfo.GetInfo(s.Type); var sb = BridgeJson.Begin("champ.update") .Ser("serial", s.Serial) .Str("category", "mini") .Str("type", s.Type.ToString()) .Str("name", s.Type.ToString()) .Str("status", status) .Bool("active", s.Active) .Num("level", s.Level) .Bool("bossUp", false) .Bool("autoRestart", true) .Str("map", s.Map == null ? null : s.Map.Name) .Num("x", s.X).Num("y", s.Y).Num("z", s.Z); if (info != null) sb.Num("maxLevel", info.MaxLevel); return sb.End(); } // ---- BaseSeaChampion (High Seas world boss) ---- // A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it // is always "active" on the board and leaves via champ.remove when slain. Position and health // are tracked so the board can show a live "world boss here, N% hp". private static string SigSea(BaseSeaChampion b) { return String.Concat( "sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString()); } private static string WriteSea(BaseSeaChampion b) { string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name; return BridgeJson.Begin("champ.update") .Ser("serial", b.Serial) .Str("category", "sea") .Str("type", b.GetType().Name) .Str("name", name) .Str("status", "active") .Bool("active", true) .Bool("bossUp", true) .Str("boss", name) .Num("hits", b.Hits) .Num("hitsMax", b.HitsMax) .Str("map", b.Map == null ? null : b.Map.Name) .Num("x", b.X).Num("y", b.Y).Num("z", b.Z) .End(); } } }