diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 949273c..c1beefe 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -19,6 +19,11 @@ StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
+# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
+# this interval to emit champ.update on any status/level/kills/boss change. The world holds
+# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
+ChampSweepSeconds=10
+
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
# support queue; the full open queue is also available on demand via pages.snapshot.
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 4456653..80e6082 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -160,6 +160,7 @@ namespace Server.Custom.Bridge
BridgeConfig.Load();
BridgeSweeps.Rearm();
BridgePages.Rearm();
+ BridgeChamps.Rearm();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break;
@@ -171,8 +172,10 @@ namespace Server.Custom.Bridge
case "sweepnow":
BridgeSweeps.SweepOnce();
+ BridgeChamps.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
break;
default:
@@ -182,6 +185,7 @@ namespace Server.Custom.Bridge
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
break;
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeChamps.cs b/overlay/Scripts/Custom/Bridge/BridgeChamps.cs
new file mode 100644
index 0000000..8c36273
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeChamps.cs
@@ -0,0 +1,287 @@
+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();
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 480ed47..627e5e4 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -18,6 +18,7 @@ namespace Server.Custom.Bridge
public static int DecaySweepSeconds { get; private set; }
public static int EconomySweepSeconds { get; private set; }
public static int PageSweepSeconds { get; private set; }
+ public static int ChampSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -55,6 +56,10 @@ namespace Server.Custom.Bridge
if (PageSweepSeconds < 1)
PageSweepSeconds = 1;
+ ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
+ if (ChampSweepSeconds < 1)
+ ChampSweepSeconds = 1;
+
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
@@ -90,9 +95,9 @@ namespace Server.Custom.Bridge
public static string Describe()
{
return String.Format(
- "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
+ "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
- AdminWriteEnabled, AdminAccessFloor);
+ ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
}
}
}