Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.
Covers all three families via a `category` field:
- champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
- mini: MiniChamp (type/level; auto-restarts, no kill counter)
- sea: BaseSeaChampion (a High Seas world-boss mobile, alive only
while summoned; removed via champ.remove when slain)
Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.
Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
288 lines
11 KiB
C#
288 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
using Server.Engines.CannedEvil;
|
|
using Server.Engines.MiniChamps;
|
|
using Server.Mobiles;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, 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.
|
|
/// </summary>
|
|
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<Serial, string> _last = new Dictionary<Serial, string>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
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<Serial>();
|
|
|
|
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
|
|
{
|
|
if (s.Deleted)
|
|
continue;
|
|
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
|
}
|
|
|
|
foreach (var s in World.Items.Values.OfType<MiniChamp>())
|
|
{
|
|
if (s.Deleted)
|
|
continue;
|
|
Track(seen, s.Serial, SigMini(s), WriteMini(s));
|
|
}
|
|
|
|
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
|
|
private static void Track(HashSet<Serial> 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();
|
|
}
|
|
}
|
|
}
|