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

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