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:
2026-09-04 14:56:49 -05:00
parent 827de04471
commit 7aa7bc8032
8 changed files with 1023 additions and 7 deletions

View File

@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Commands;
using Server.Custom.Bridge;
using Server.Engines.CannedEvil;
using Server.Mobiles;
namespace Server.Custom
{
/// <summary>
/// Exercises the two halves of Protocol 6 on a live shard, without a game client.
///
/// **Idempotency needs no probe.** It is driven from the OTHER end — two identical POSTs to
/// the sidecar, the second of which must come back `replayed: true` under its own reqId — so
/// a curl and the shard's own audit trail are the whole test. Nothing here would make that
/// more convincing.
///
/// `champ.boss.killed` is the opposite case. It cannot be produced from outside the game at
/// all: a champion boss appears only when a spawn is driven to its final level, and the
/// damage table the frame carries is assembled by real combat against a real creature. A
/// fixture can assert the shape of the JSON; only this proves that
/// `EventSink.CreatureDeath` fires for a `BaseChampion`, that `DamageEntries` still holds
/// anything by the time it does, and that the sweep's spawn attribution is there to name the
/// altar.
///
/// What it does, in order:
///
/// 1. Places a real `ChampionSpawn`, activates it and calls `SpawnChampion()` — the
/// shard's own code path, not a hand-constructed creature.
/// 2. Waits for the champ sweep to see it, so the boss is attributed to its altar exactly
/// the way a real one would be. **This wait is the assertion**: run without it and the
/// kill still emits, but with no `serial`, `type` or `level` — which is the phase's own
/// documented fallback rather than the case being tested.
/// 3. Damages it from two real player mobiles found in the world, so the damage table has
/// two ranked entries rather than none.
/// 4. Kills it and cleans up the altar.
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// In game / at the console: `[p6probe`. Flag: `Protocol6ProbeOnStart`.
/// **Spawns and kills a champion boss.** Use on a rig, never on a live shard.
/// </summary>
public static class BridgeProtocol6Probe
{
private static ChampionSpawn _spawn;
public static void Initialize()
{
CommandSystem.Register("p6probe", AccessLevel.Administrator, Probe_OnCommand);
if (Config.Get("Bridge.Protocol6ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Run(null));
}
[Usage("p6probe")]
[Description("Spawns a champion boss, damages it from two players and kills it.")]
private static void Probe_OnCommand(CommandEventArgs e)
{
Run(e == null ? null : e.Mobile);
}
private static void Say(Mobile to, string text)
{
Console.WriteLine("[p6probe] {0}", text);
if (to != null)
to.SendMessage(text);
}
private static void Run(Mobile from)
{
try
{
// Inside a NAMED region, deliberately. A champion altar really lives in a dungeon
// and the first version of this probe put one there — but the dungeon floor at
// Destard belongs to the map's default region, whose Name is empty, so the emitted
// frame carried no `region` at all and the one field a phase condition is most
// likely to match on ("the boss in Yew") went unproven. Britain has a named region,
// so this exercises the field rather than the guard that omits it.
var where = new Point3D(1496, 1628, 10);
var map = Map.Felucca;
Cleanup();
_spawn = new ChampionSpawn();
_spawn.MoveToWorld(where, map);
_spawn.Type = ChampionSpawnType.Abyss;
_spawn.AutoRestart = false;
_spawn.Active = true;
Say(from, "altar placed; spawning its champion");
_spawn.SpawnChampion();
var boss = _spawn.Champion;
if (boss == null)
{
Say(from, "FAILED: the spawn produced no champion");
Cleanup();
return;
}
Say(from, String.Format("champion up: {0} ({1}) serial {2} region {3}",
boss.Name, boss.GetType().Name, boss.Serial,
boss.Region == null ? "(none)" : ("\"" + boss.Region.Name + "\"")));
// Give the sweep time to attribute the boss to its altar. Two intervals, because a
// single one races the timer that is already part-way through its period.
var wait = TimeSpan.FromSeconds(Math.Max(2.0, BridgeConfig.ChampSweepSeconds * 2.0));
Say(from, String.Format("waiting {0:0}s for the champ sweep to see it", wait.TotalSeconds));
Timer.DelayCall(wait, () => Finish(from, boss));
}
catch (Exception ex)
{
Say(from, "threw: " + ex);
Cleanup();
}
}
private static void Finish(Mobile from, Mobile boss)
{
try
{
if (boss == null || boss.Deleted)
{
Say(from, "FAILED: the champion vanished before it could be killed");
Cleanup();
return;
}
// Two real players, so the damage table has two ranked entries and the ranking is
// testable rather than trivially one row. Registered through Mobile.RegisterDamage,
// which is the same call combat makes.
var players = World.Mobiles.Values
.OfType<PlayerMobile>()
.Where(p => !p.Deleted && p.Account != null)
.Take(2)
.ToList();
if (players.Count < 2)
{
Say(from, "note: fewer than two player mobiles in the world; the table will be short");
}
for (int i = 0; i < players.Count; i++)
{
// Deliberately unequal and deliberately in ascending order, so a frame that
// reported them in arrival order rather than by damage would be visibly wrong.
int amount = 120 * (i + 1);
boss.RegisterDamage(amount, players[i]);
Say(from, String.Format("registered {0} damage from {1}", amount, players[i].Name));
}
var killer = players.Count > 0 ? players[players.Count - 1] : null;
Say(from, "killing the champion");
boss.Damage(boss.HitsMax * 10, killer);
if (!boss.Deleted && boss.Alive)
{
Say(from, "note: it survived the blow; killing it outright");
boss.Kill();
}
Timer.DelayCall(TimeSpan.FromSeconds(2.0), () =>
{
Cleanup();
Say(from, "done — check the sidecar feed for champ.boss.killed");
});
}
catch (Exception ex)
{
Say(from, "threw: " + ex);
Cleanup();
}
}
private static void Cleanup()
{
if (_spawn == null)
return;
try
{
_spawn.Active = false;
_spawn.Delete();
}
catch
{
// The altar is scaffolding; failing to tidy it is not worth an exception.
}
_spawn = null;
}
}
}

View File

@@ -16,6 +16,7 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** |
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. |
## Deploy overwrites Bridge.cfg
@@ -170,3 +171,20 @@ what `BridgeRigDriver` and its `rigcmd.txt` are for.
Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a
killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar —
use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested.
## The innermost region has no name
`BridgeProtocol6Probe` places its altar in the middle of **Britain** rather than at a dungeon altar,
and that is not cosmetic. An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its own
spawn area, constructed with a **null name** and with the town region as its parent -- so the most
specific region containing a champion boss is the one region on the map guaranteed to be nameless.
`Mobile.Region` then hides that by falling back to the map's unnamed default region rather than to
null, and the emitted frame simply has no `region`.
Region registration is also **deferred**, which is what makes this survive a first look: a lookup
taken immediately after the altar is placed answers `"Britain"`, and one taken at the kill twenty
seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the
value that lies, printed next to a frame that disagrees with it.
Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately
absent and the probe proves nothing about it.