A command carrying an `idempotencyKey` is now executed at most once: a repeat is answered with the original reply rather than re-run. That is the precondition every world verb in Phase 12 is waiting on, and it is what let `uo.broadcast` stop being un-retryable. The gate sits in BridgeBoot's inbound dispatch, not in each handler, so it covers every kind including ones a later protocol adds. A command with no key behaves exactly as it did before, which leaves the admin screens unchanged. Four rules, each a decision rather than an implementation detail: reserve on receipt (so a handler that defers is covered, answering `bridge.busy` to a repeat in flight); a key that has begun is never released, not even when the handler throws; a replay is stamped with the REPEAT's correlation id, because the sidecar's reqId is fresh per call and replaying the original would hang the retry; and the bound is loud, because an evicted key is the guarantee's one hole. `champ.boss.killed` rides along because a bump costs a release, a bundle and an operator update on every shard. It fires from EventSink.CreatureDeath, detected by type so a boss that popped and died inside one sweep is still reported, and it carries the damage table that exists at the death and nowhere else. overlay.toml protocol = 6, in this commit rather than a later one. Co-Authored-By: Claude <noreply@anthropic.com>
484 lines
21 KiB
C#
484 lines
21 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>();
|
|
|
|
// 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<Serial, Serial> _bossOf = new Dictionary<Serial, Serial>();
|
|
|
|
// 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.
|
|
}
|
|
|
|
/// <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} bossKills={4} bossesUp={5})",
|
|
_sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count);
|
|
}
|
|
|
|
// ---- champ.boss.killed (Protocol 6) ----
|
|
|
|
/// <summary>
|
|
/// Fires for every creature death on the shard, so the first thing it does is decide
|
|
/// this is not one. Detection is by TYPE — <c>BaseChampion</c>, which
|
|
/// <c>BaseSeaChampion</c> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<KeyValuePair<Mobile, int>> TopDamagers(Mobile boss)
|
|
{
|
|
var totals = new Dictionary<Mobile, int>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
|
|
// 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<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++;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
}
|
|
}
|