Files
servuo-plugins/tools/scaffolding/BridgeProtocol6Probe.cs
wtclaude 7aa7bc8032 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>
2026-09-04 14:56:49 -05:00

202 lines
7.9 KiB
C#

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;
}
}
}