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:
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user