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>
202 lines
7.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|