Merge pull request 'feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)' (#21) from feature/protocol-v6-idempotency into edge

Reviewed-on: #21
This commit is contained in:
2026-09-04 23:06:18 +00:00
8 changed files with 1023 additions and 7 deletions

View File

@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
# Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result).
protocol = 5
# Current: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed).
protocol = 6
# ── ServUO compatibility ─────────────────────────────────────────────────────
#

View File

@@ -133,7 +133,42 @@ namespace Server.Custom.Bridge
return;
}
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
// most once: a repeat is answered with the original reply rather than re-run. The gate
// is here rather than in each handler so it covers every inbound kind — including the
// ones a later protocol adds, which is the half that is easy to forget. A command with
// no key behaves exactly as it did before, which is what keeps the admin screens (which
// send none) unchanged.
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
if (idempotencyKey == null)
{
handler(obj);
return;
}
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
return; // already answered: a replay of the original reply, or bridge.busy
string error = null;
try
{
handler(obj);
}
catch (Exception ex)
{
// Swallowed deliberately, and only on the keyed path: the key must be closed out
// with a definite answer (see BridgeIdempotency's header) rather than left in
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
// way they always have.
error = ex.Message;
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
}
finally
{
BridgeIdempotency.Finish(idempotencyKey, error);
}
}
private static void OnPing(Dictionary<string, object> o)
@@ -215,6 +250,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
break;
}
}

View File

@@ -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)
{

View File

@@ -0,0 +1,439 @@
using System;
using System.Collections.Generic;
namespace Server.Custom.Bridge
{
/// <summary>
/// Protocol 6. Makes a repeated command safe.
///
/// The website's event runner retries a step that did not come back, and until now a command
/// whose acknowledgement was lost was indistinguishable from one that never applied. There
/// was no way to tell the difference from either end, so every world-writing verb had to be
/// declared un-retryable — a lost announcement being cheaper than a doubled one. That is not
/// a position you can hold once an event can spawn creatures or lease a config value.
///
/// So a command may now carry an `idempotencyKey`, and the shard promises: **a key is
/// executed at most once.** A repeat is never re-run. It is answered with the original
/// reply — the same acknowledgement the caller lost — under the repeat's own correlation id.
///
/// ── Reserve on receipt, not on completion ──────────────────────────────────────────────
///
/// The key is recorded BEFORE the handler is dispatched, not after it returns. A handler that
/// finishes inside its own inbound call can never see a repeat (the Core thread processes one
/// line at a time), but a handler that defers — a lease that arms a timer, a spawn that
/// waits for a save — completes long after `OnInboundLine` has returned, and that is exactly
/// the window a lost ack opens. Reserving late would leave it uncovered.
///
/// A repeat of a key that is still in flight is answered `bridge.busy`: it runs nothing and
/// tells the caller to come back. `bridge.busy` is deliberately not an error — the work is
/// happening, and the module classifies it retryable.
///
/// ── A key that has begun is never released ────────────────────────────────────────────
///
/// Not even when the handler throws. Releasing it would let a retry re-run a command that may
/// have applied half of itself, which is precisely the failure this file exists to prevent.
/// A handler that throws stores a `bridge.error` reply instead, so the retry gets a definite
/// answer and the step fails once rather than looping.
///
/// ── The one hole, and why it is loud ──────────────────────────────────────────────────
///
/// The set is bounded, so an evicted key's repeat WOULD be applied a second time. The bounds
/// are chosen to put that far outside reach — an hour, against core's fifteen-minute step
/// lease — and an eviction that drops a key which had not yet expired prints a warning naming
/// the count. If the guarantee is ever actually breached, an operator sees it here rather
/// than discovering a doubled spawn in the world.
/// </summary>
public static class BridgeIdempotency
{
/// <summary>
/// How long a key is remembered. Core's step lease is 15 minutes and its retry backoff
/// is bounded well inside that, so an hour is not a tuned number — it is a margin wide
/// enough that expiry should never be the thing that ends a key's life.
/// </summary>
private static readonly TimeSpan Ttl = TimeSpan.FromHours(1.0);
/// <summary>
/// Hard bound on remembered keys, in the same spirit as BridgeLink's outbound queue cap:
/// the Core thread never holds an unbounded collection. At command rates this is days of
/// traffic, so reaching it means something is wrong — hence the warning on eviction.
/// </summary>
private const int Cap = 4096;
/// <summary>
/// The correlation fields the sidecar routes replies on, in the order `rpc.rs` tries
/// them. A command carries exactly one; the reply echoes it. A replay must be stamped
/// with the REPEAT's value, not the original's — the sidecar's `reqId` is a fresh
/// per-process counter, so the retry is waiting on an id the first attempt never used.
/// </summary>
private static readonly string[] CorrFields = { "reqId", "code", "id" };
private sealed class Entry
{
public DateTime Reserved; // when the key was first seen
public bool Done; // the handler has finished (successfully or not)
public string Reply; // the correlated reply line, verbatim; null if there was none
public string Corr; // the correlation value the original reply carries
public string CorrField; // which of CorrFields that value sits in
public string Kind; // for diagnostics only
}
private static readonly Dictionary<string, Entry> _byKey =
new Dictionary<string, Entry>(StringComparer.Ordinal);
// Insertion order, so the cap evicts oldest-first without sorting the dictionary.
private static readonly Queue<string> _order = new Queue<string>();
// ---- capture state; Core thread only, one keyed command at a time ----
private static Entry _open;
private static string _openCorr;
private static string _openCorrField;
private static long _seen, _replayed, _busy, _evicted, _uncorrelated;
/// <summary>
/// True while a keyed command's handler is running. BridgeLink.Emit checks this on every
/// emit, so it is a plain bool read rather than anything that costs the sweep path.
/// </summary>
public static bool Capturing
{
get { return _open != null; }
}
public static string Status()
{
return String.Format(
"idem(keys={0} seen={1} replayed={2} busy={3} evicted={4} uncorrelated={5})",
_byKey.Count, _seen, _replayed, _busy, _evicted, _uncorrelated);
}
/// <summary>
/// Called by BridgeBoot for every inbound command that carries an `idempotencyKey`,
/// before the handler runs.
///
/// Returns TRUE when the command must not be executed — this call has already emitted the
/// answer (a replay of the original reply, or `bridge.busy`). Returns FALSE when the key
/// is new: the key is now reserved and capture is open, and the caller MUST pair this
/// with <see cref="Finish"/> in a finally.
/// </summary>
public static bool Intercept(string key, Dictionary<string, object> command)
{
_seen++;
Sweep();
string corrField = null;
string corr = null;
for (int i = 0; i < CorrFields.Length; i++)
{
var v = BridgeJson.GetString(command, CorrFields[i]);
if (v != null)
{
corrField = CorrFields[i];
corr = v;
break;
}
}
Entry prior;
if (_byKey.TryGetValue(key, out prior))
{
if (prior.Done)
Replay(key, prior, corrField, corr);
else
Busy(key, prior, corrField, corr);
return true;
}
var entry = new Entry
{
Reserved = DateTime.UtcNow,
Done = false,
Kind = BridgeJson.GetString(command, "kind"),
};
Remember(key, entry);
_open = entry;
_openCorr = corr;
_openCorrField = corrField;
return false;
}
/// <summary>
/// Called by BridgeBoot in a finally, once the handler has returned. Closes capture and
/// marks the key done. `error` is non-null when the handler threw.
///
/// A handler that deferred its work calls <see cref="Hold"/> first; this then leaves the
/// key reserved and in flight, and the handler completes it later.
/// </summary>
public static void Finish(string key, string error)
{
var entry = _open;
_open = null;
var corr = _openCorr;
var corrField = _openCorrField;
_openCorr = null;
_openCorrField = null;
if (entry == null || entry.Done)
return; // Hold() released it to its own completion, or there was nothing open
if (error != null)
{
// The handler threw. The key stays claimed — see the class header — and the stored
// answer is the failure, so the retry ends the step instead of re-running a command
// that may have applied part of itself.
var sb = BridgeJson.Begin("bridge.error");
if (corrField != null)
sb.Str(corrField, corr);
sb.Str("reason", "handler threw: " + error)
.Str("idempotencyKey", key);
entry.Reply = sb.End();
entry.Corr = corr;
entry.CorrField = corrField;
entry.Done = true;
Console.WriteLine("[Bridge] idempotency: {0} threw under key {1}; the retry will be answered with the failure",
entry.Kind, key);
return;
}
if (entry.Reply == null)
{
// Nothing the sidecar could have correlated was emitted. That is a defect in the
// handler rather than a state to model: the FIRST attempt has already timed out at
// the sidecar, and the retry would time out identically forever. Store a definite
// answer so the retry terminates, and say so.
_uncorrelated++;
var sb = BridgeJson.Begin("bridge.error");
if (corrField != null)
sb.Str(corrField, corr);
sb.Str("reason", "the original command produced no correlated reply")
.Str("idempotencyKey", key);
entry.Reply = sb.End();
entry.Corr = corr;
entry.CorrField = corrField;
Console.WriteLine("[Bridge] idempotency: {0} under key {1} emitted no reply the sidecar could correlate",
entry.Kind, key);
}
entry.Done = true;
}
/// <summary>
/// For a handler that finishes AFTER its inbound call returns. It keeps the key reserved
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
/// calling <see cref="Complete"/> with the reply it eventually emits.
///
/// Nothing in protocol 6 defers yet. The door exists because the leases and world verbs
/// that follow do, and because a deferred handler that had no way to hold its key would
/// quietly be the one place the guarantee did not hold.
/// </summary>
public static void Hold(string key)
{
var entry = _open;
if (entry == null)
return;
// Close capture without marking done: the key stays in flight until Complete.
_open = null;
_openCorr = null;
_openCorrField = null;
}
/// <summary>
/// Completes a key a handler previously held. `replyLine` is the line the handler emits
/// as its answer; it is stored so a later repeat replays it.
/// </summary>
public static void Complete(string key, string replyLine)
{
Entry entry;
if (key == null || !_byKey.TryGetValue(key, out entry) || entry.Done)
return;
var parsed = replyLine == null ? null : BridgeJson.Parse(replyLine);
if (parsed != null)
{
for (int i = 0; i < CorrFields.Length; i++)
{
var v = BridgeJson.GetString(parsed, CorrFields[i]);
if (v != null)
{
entry.CorrField = CorrFields[i];
entry.Corr = v;
break;
}
}
}
entry.Reply = replyLine;
entry.Done = true;
}
/// <summary>
/// Every line a keyed handler emits passes through here. Only the one the sidecar would
/// correlate with THIS command is kept: an `admin.audit` broadcast that happens to be
/// emitted alongside the reply is a fact about the world and must not be replayed, while
/// the reply is an answer to a caller and must be.
/// </summary>
public static void Observe(string line)
{
var entry = _open;
if (entry == null || line == null || _openCorrField == null || _openCorr == null)
return;
// Cheap reject before parsing: the correlation value is a string field on the reply, so
// if it does not appear in the line at all this cannot be the reply.
if (line.IndexOf(_openCorr, StringComparison.Ordinal) < 0)
return;
var parsed = BridgeJson.Parse(line);
if (parsed == null)
return;
if (!String.Equals(BridgeJson.GetString(parsed, _openCorrField), _openCorr, StringComparison.Ordinal))
return;
entry.Reply = line;
entry.Corr = _openCorr;
entry.CorrField = _openCorrField;
}
// ---- internals ----
private static void Replay(string key, Entry prior, string corrField, string corr)
{
_replayed++;
// A repeat with no correlation field is nobody's outstanding call. Re-emitting the
// original reply would put a stale answer on the event feed, where a subscriber would
// read it as a fresh one, so the repeat is absorbed silently instead.
if (corrField == null || corr == null)
{
Console.WriteLine("[Bridge] idempotency: absorbed an uncorrelated repeat of key {0} ({1})",
key, prior.Kind);
return;
}
// Stamp the repeat's correlation id over the original's. The sidecar is waiting on the
// id IT sent this time; replaying the first attempt's id would leave the call hanging
// until the reply timeout, which is the very failure being answered.
string line = null;
if (prior.Reply != null && String.Equals(corrField, prior.CorrField, StringComparison.Ordinal))
line = BridgeJson.RewriteStringField(prior.Reply, corrField, corr);
if (line == null)
{
// Either the original produced no reply to replay, or the repeat correlates on a
// different field than the original did. Nothing sensible can be replayed under an
// id the caller is not waiting on, so answer plainly rather than hang the call.
BridgeLink.Emit(BridgeJson.Begin("bridge.error")
.Str(corrField, corr)
.Str("reason", "the original reply for this idempotency key cannot be replayed")
.Str("idempotencyKey", key)
.End());
return;
}
line = BridgeJson.WithTrueFlag(line, "replayed");
Console.WriteLine("[Bridge] idempotency: replaying the original reply for key {0} ({1})",
key, prior.Kind);
BridgeLink.Emit(line);
}
private static void Busy(string key, Entry prior, string corrField, string corr)
{
_busy++;
var sb = BridgeJson.Begin("bridge.busy");
if (corrField != null)
sb.Str(corrField, corr);
sb.Str("idempotencyKey", key)
.Str("kind", prior.Kind)
.Str("reason", "a command with this idempotency key is still in flight");
BridgeLink.Emit(sb.End());
}
private static void Remember(string key, Entry entry)
{
_byKey[key] = entry;
_order.Enqueue(key);
while (_order.Count > Cap)
{
var oldest = _order.Dequeue();
Entry dropped;
if (!_byKey.TryGetValue(oldest, out dropped))
continue;
_byKey.Remove(oldest);
// Expired keys leave silently; they are supposed to. A key evicted while still
// inside its TTL is the guarantee's one hole, so it never leaves quietly.
if (DateTime.UtcNow - dropped.Reserved < Ttl)
{
_evicted++;
Console.WriteLine(
"[Bridge] idempotency: evicted key {0} ({1}) while still live — the cap of {2} was reached, so a repeat of it WOULD be applied again ({3} so far)",
oldest, dropped.Kind, Cap, _evicted);
}
}
}
/// <summary>Drops keys past their TTL. Runs on the command path, which is human-rate.</summary>
private static void Sweep()
{
if (_order.Count == 0)
return;
var cutoff = DateTime.UtcNow - Ttl;
while (_order.Count > 0)
{
var oldest = _order.Peek();
Entry entry;
if (!_byKey.TryGetValue(oldest, out entry))
{
_order.Dequeue();
continue;
}
if (entry.Reserved > cutoff)
return; // insertion-ordered, so nothing behind this is older
_order.Dequeue();
_byKey.Remove(oldest);
}
}
}
}

View File

@@ -139,6 +139,53 @@ namespace Server.Custom.Bridge
return sb;
}
/// <summary>
/// A named array of actor objects each carrying a damage total — a boss kill's damage
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
/// merely listed.
///
/// The pairs are written in the order given, so the CALLER owns the sort. That is
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
/// to report the numbers it holds rather than to decide what counts as a contribution.
///
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
/// every other actor is. A shard that considers the whole table too revealing hides it
/// with one field rule rather than by dropping the kind.
/// </summary>
public static StringBuilder Damagers(
this StringBuilder sb, string name, IList<KeyValuePair<Mobile, int>> pairs, int count)
{
sb.Append(",\"").Append(name).Append("\":[");
if (pairs != null)
{
var end = Math.Min(count, pairs.Count);
bool first = true;
for (int i = 0; i < end; i++)
{
var m = pairs[i].Key;
if (m == null)
continue;
if (!first)
sb.Append(',');
sb.Append('{');
WriteActorFields(sb, m);
sb.Append(",\"damage\":").Append(pairs[i].Value);
sb.Append('}');
first = false;
}
}
sb.Append(']');
return sb;
}
/// <summary>
/// A roster member: the standard actor object plus the member's rank in their guild.
///
@@ -289,6 +336,75 @@ namespace Server.Custom.Bridge
sb.Append('"');
}
// ---- rewriting an already-built line (protocol 6) ----
//
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
// parse the line, edit the dictionary and re-serialize, but a round trip through
// JavaScriptSerializer would silently renormalise every number and string in a reply this
// file went to the trouble of writing by hand. These two edit the text instead, so a
// replayed reply is byte-for-byte the original apart from the field that had to change.
/// <summary>
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
/// finding the value's end. Returns null if the field is not present as a string —
/// never a half-rewritten line.
/// </summary>
public static string RewriteStringField(string line, string name, string value)
{
if (line == null || name == null || value == null)
return null;
// The leading comma is part of the needle: every top-level field is written by Str()
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
// is the cheapest thing that stops the search matching the same text inside a value.
var needle = ",\"" + name + "\":\"";
int at = line.IndexOf(needle, StringComparison.Ordinal);
if (at < 0)
return null;
int valueStart = at + needle.Length;
int i = valueStart;
while (i < line.Length)
{
char c = line[i];
if (c == '\\')
{
i += 2; // an escape consumes the next character, whatever it is
continue;
}
if (c == '"')
break;
i++;
}
if (i >= line.Length)
return null; // unterminated: refuse rather than guess
var sb = new StringBuilder(line.Length + value.Length);
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
Escape(sb, value);
sb.Append(line, i + 1, line.Length - i - 1);
return sb.ToString();
}
/// <summary>
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
/// is not one, so a malformed reply is passed through rather than corrupted further.
/// </summary>
public static string WithTrueFlag(string line, string name)
{
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
return line;
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
}
// ---- inbound ----
/// <summary>

View File

@@ -107,7 +107,17 @@ namespace Server.Custom.Bridge
/// </summary>
public static void Emit(string line)
{
if (!_running || line == null)
if (line == null)
return;
// Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every
// line it emits is offered to the recent-key store so the correlated reply can be
// replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link
// was too dead to deliver is precisely the one a retry will come back for.
if (BridgeIdempotency.Capturing)
BridgeIdempotency.Observe(line);
if (!_running)
return;
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over

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.