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:
@@ -133,7 +133,42 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
}
|
||||
|
||||
handler(obj);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user