feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)
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>
This commit is contained in:
@@ -41,7 +41,17 @@ namespace Server.Custom.Bridge
|
||||
// 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;
|
||||
// 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()
|
||||
{
|
||||
@@ -56,12 +66,24 @@ namespace Server.Custom.Bridge
|
||||
// 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>
|
||||
@@ -82,8 +104,160 @@ namespace Server.Custom.Bridge
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||
_sweeps, _emitted, _removed, _last.Count);
|
||||
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>
|
||||
@@ -107,6 +281,15 @@ namespace Server.Custom.Bridge
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -133,6 +316,19 @@ namespace Server.Custom.Bridge
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user