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(); // Protocol 6. Which spawn a live champion belongs to, refreshed by the sweep. The kill itself // is detected by TYPE (see OnCreatureDeath), so this map only ever supplies CONTEXT — which // altar, at what level. A boss that popped and died inside one sweep interval is still // reported; it simply arrives without its spawn. private static readonly Dictionary _bossOf = new Dictionary(); // How many damage entries a kill reports. Deep enough that a real champion fight's meaningful // contributors are all present, shallow enough that the frame stays one line on the wire. private const int MaxDamagers = 20; private static long _sweeps, _emitted, _removed, _bossKills; 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; // Protocol 6. A boss defeat was previously only INFERABLE — champ.update going bossUp // true then false, correlated against a mob.killed nearby — and that inference is both // fragile and silent about who did the work. It is a real moment in a shard's week and // an event's phase condition wants to name it, so it becomes a kind of its own. EventSink.CreatureDeath += OnCreatureDeath; Rearm(); } private static void OnConnected() { _last.Clear(); // _bossOf is deliberately NOT cleared. It is a fact about the world, not a diff cache: // dropping it on a sidecar reconnect would lose the spawn attribution for a boss that is // up right now, and it refills from the sweep only if that boss's record happens to // change again before it dies. } /// 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} bossKills={4} bossesUp={5})", _sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count); } // ---- champ.boss.killed (Protocol 6) ---- /// /// Fires for every creature death on the shard, so the first thing it does is decide /// this is not one. Detection is by TYPE — BaseChampion, which /// BaseSeaChampion derives from, so one check covers both families — with the /// sweep's map used only to name the altar. A boss that popped and died between two /// sweeps is therefore still reported; it simply arrives without a spawn. /// /// The damage table is read here and nowhere else, because it exists here and nowhere /// else: ServUO discards a creature's damage entries with the creature, and the shard is /// the only party that ever sees them. Entries are reported whether or not ServUO /// considers them expired — expiry governs LOOTING RIGHTS, and someone who fought the /// first two thirds of a champion fight and then died took part in it regardless of what /// they are owed from the corpse. /// private static void OnCreatureDeath(CreatureDeathEventArgs e) { try { var boss = e.Creature; if (boss == null) return; Serial spawnSerial; bool attributed = _bossOf.TryGetValue(boss.Serial, out spawnSerial); if (!(boss is BaseChampion) && !attributed) return; _bossOf.Remove(boss.Serial); _bossKills++; var spawn = attributed ? World.FindItem(spawnSerial) as ChampionSpawn : null; var name = String.IsNullOrEmpty(boss.Name) ? boss.GetType().Name : boss.Name; var sb = BridgeJson.Begin("champ.boss.killed") .Str("category", boss is BaseSeaChampion ? "sea" : "champion") .Ser("bossSerial", boss.Serial) .Str("boss", name) .Str("bossType", boss.GetType().Name) .Str("map", boss.Map == null ? null : boss.Map.Name) .Num("x", boss.X).Num("y", boss.Y).Num("z", boss.Z); // The altar's own record, when the kill could be attributed to one. `serial` is the // SPAWN here, matching champ.update, so a consumer can join the two without a rule // about which of two serials on the frame means what. if (spawn != null) { sb.Ser("serial", spawn.Serial) .Str("type", spawn.Type.ToString()) .Num("level", spawn.Level); } // A named region is what a phase condition can actually match on ("the boss in // Yew"); coordinates are not. Emitted alongside the coordinates rather than // instead, because large stretches of the map belong to no named region at all. // // **The innermost region here is ANONYMOUS, and the rig is the only thing that was // ever going to say so.** A champion killed in the middle of Britain produced a // frame with no region at all, because an active `ChampionSpawn` registers a // `ChampionSpawnRegion` over its own spawn area — constructed with a null name and // with the town region as its PARENT (`ChampionSpawn.cs`, its constructor). So the // most specific region containing a champion boss is, by construction, the one // region on the map guaranteed to have no name. // // It also explains why this looked fine for twenty seconds: region registration is // deferred, so a lookup immediately after the altar is placed still answers // "Britain" and one at the kill does not. A first read at spawn time would have // confirmed a bug into the design. // // Walking to the nearest NAMED ancestor is the general answer rather than a special // case for champions: a house region, a dungeon sub-region and a guarded-zone // overlay are all anonymous children of somewhere a player would name. var region = NamedRegionAt(boss.Location, boss.Map); if (region != null) sb.Str("region", region); if (e.Killer != null) sb.Actor("killer", e.Killer); sb.Damagers("damagers", TopDamagers(boss), MaxDamagers); BridgeLink.Emit(sb.End()); } catch (Exception ex) { // A death handler must never be the thing that breaks a death. Console.WriteLine("[Bridge] champ.boss.killed threw: {0}", ex.Message); } } /// /// Player damage against this creature, highest first. Totals are summed per damager /// rather than trusted to be one entry each: ServUO's own registration folds repeat /// damage into an existing entry, but an entry that expired and was re-created leaves /// two, and a table that listed the same player twice would be read as two participants. /// /// /// The nearest NAMED region containing a point, walking outward from the most specific /// one, or null when nothing on the way out has a name. /// /// Null rather than "" so the caller can leave the field off the frame entirely: a /// consumer reading `region: ""` cannot tell "outdoors, nowhere in particular" from /// "somewhere, but the shard would not say", and only one of those is true here. /// /// The map's own default region terminates the walk with its parentless empty name, so /// a point in open countryside answers null without a special case. /// private static string NamedRegionAt(Point3D p, Map map) { if (map == null) return null; for (var region = Region.Find(p, map); region != null; region = region.Parent) { if (!String.IsNullOrEmpty(region.Name)) return region.Name; } return null; } private static List> TopDamagers(Mobile boss) { var totals = new Dictionary(); var entries = boss.DamageEntries; if (entries != null) { for (int i = 0; i < entries.Count; i++) { var de = entries[i]; if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player) continue; int running; totals.TryGetValue(de.Damager, out running); totals[de.Damager] = running + de.DamageGiven; } } var ranked = totals.ToList(); ranked.Sort((a, b) => b.Value.CompareTo(a.Value)); return ranked; } /// 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; // Protocol 6. Remember which altar a live champion belongs to so its death can // name one. Recorded here rather than looked up at death because the lookup // would be a scan of World.Items on every creature death on the shard. var champion = s.Champion; if (champion != null && !champion.Deleted) _bossOf[champion.Serial] = s.Serial; 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++; } // A defeated champion's attribution is consumed by OnCreatureDeath, but one deleted // by a GM or lost to a world reload never dies, so the map is swept too. Cheap: it // holds at most one entry per altar with a boss currently up. if (_bossOf.Count > 0) { var vanished = _bossOf.Keys .Where(k => { var m = World.FindMobile(k); return m == null || m.Deleted; }) .ToList(); foreach (var k in vanished) _bossOf.Remove(k); } } 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(); } } }