feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a) #21
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/protocol-v6-idempotency"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
EVENTS_PLAN.mdPhase 11a, the plugin half. The first protocol bump that adds a promise rather than data, and the shard is where the whole promise lives — the sidecar's part is one constant and one status mapping.Nothing on the wire could say that before, and
EVENTS.md§A row 7 records what it cost: "a command whose ack is lost is indistinguishable from one that never applied." So every world-writing verb had to be declared un-retryable — Phase 9 shippeduo.broadcastansweringretry: falseto everything, including a 503 from a shard that was merely restarting. Sound reasoning, real trade, and not one that survives an event that spawns creatures or holds a lease. §G says so in as many words: required before any world write ships.overlay.tomlisprotocol = 6in this PR, not a later one.The four rules, each a decision rather than an implementation detail
OnInboundLinereturned, and that is precisely the window a lost ack opens. A repeat of a key still in flight getsbridge.busy: nothing runs, come back. Deliberately not spelledbridge.busy.error— nothing is wrong, the work is happening.bridge.errorinstead, so the retry gets a definite answer and the step fails once rather than looping.reqIdis a fresh per-process counter, so a retry waits on an id the first attempt never used; replaying the original verbatim would hang the call until the reply timeout — the very failure being answered.reqId,codeandidare all handled, and a repeat correlating on a different field than the original is refused rather than answered under an id nobody is waiting on.The gate is in
BridgeBoot's inbound dispatch rather than in each handler, so it covers every kind including the ones a later protocol adds. A command with no key behaves exactly as before, which is what leaves the admin screens unchanged.How the reply is captured. Handlers emit through
BridgeLink.Emit, so there is nothing to return. While a keyed handler runs,Emitoffers each line to the store, which keeps only the line the sidecar would correlate with this command. That filter is the point: anadmin.auditframe emitted alongside the reply is a fact about the world and must not be replayed; the reply is an answer to a caller and must be. Capture happens beforeEmit's_runningcheck, too — a reply the link was too dead to deliver is exactly the one a retry comes back for. Replay rewrites the correlation field in the stored text rather than round-tripping throughJavaScriptSerializer, so a replayed reply is byte-for-byte the original apart from the field that had to change.champ.boss.killedRides along because a bump costs a release, a bundle and an operator update on every shard — a field left out costs a second bump, not a follow-up commit.
§G had a boss defeat as 🔗 inferable from
champ.updatelosingbossUpnext to amob.killed, and called that "slightly fragile". It is worse than that:bossUpalso drops when a GM resets a spawn, when a boss despawns, and after a reconnect clears the sweep's diff cache. And the inference is silent about who fought.Fired from
EventSink.CreatureDeathand detected by type (BaseChampion, whichBaseSeaChampionderives from), so a boss that popped and died inside one sweep interval is still reported — the sweep's map supplies only the altar context. The damage table is read here because it exists here and nowhere else: ServUO discards a creature's damage entries with the creature. Totals are summed per damager rather than trusted to be one entry each, and entries are reported whether or not ServUO considers them expired — expiry governs looting rights, and someone who fought two thirds of the fight and then died took part in it.The live walk, and the defect it found
Real ServUO (43k mobiles, 209k items), the release Rust sidecar, and a new
tools/scaffolding/BridgeProtocol6Probe.cs.Three
POST /admin/broadcast, two under one key. Attempt 2 answered{"kind":"admin.ok","replayed":true,"reqId":"r-2","t":1788550182074}— the first attempt'st, so it is stored rather than re-run, underr-2rather than ther-1the first attempt used. The shard's own audit trail holds two rows, not three. Theid-correlated plane was walked too: twoPOST /towncrierunder one key, the secondreplayedand correlated onid.A champion killed in the middle of Britain emitted a frame with no
region. Three things were true at once, and the naive check confirms the bug:ChampionSpawnregisters aChampionSpawnRegionover its spawn area — constructed with a null name and the town region as itsParent. So the most specific region containing a champion boss is, by construction, the one region on the map guaranteed to have no name.Mobile.Regionnever returns null: with no cached region it falls back to the map's unnamed default. The failure looks identical to "outdoors, nowhere in particular"."Britain"and one at the kill twenty seconds later does not. The probe printedregion "Britain"on every run, including the ones whose frame carried nothing. A read at spawn time would have confirmed the bug into the design.Fixed by walking outward to the nearest named ancestor — general rather than a champion special case, since house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of somewhere a player would name. The map's own default terminates the walk, so open countryside answers null and the field is omitted: a consumer reading
region: ""cannot tell "nowhere in particular" from "the shard would not say".Final frame:
boss: Semidar,serial: 0x400150E8/type: Abyss/level: 0(the altar, attributed),region: "Britain", damagers[("tester", 100240), ("Darrow", 120)]— descending, andtester's registered 240 folded into the 100000 killing blow as one entry rather than two.One thing the rig could not prove
bridge.busy. With today's synchronous handlers a repeat cannot arrive while the original runs, so the state is unreachable on a live shard until a handler defers. It is implemented,BridgeIdempotency.Hold/Completeare the door a deferring handler uses, and the sidecar's 425 mapping is unit-tested on all three responders. Phase 11b's leases are the first thing that can produce it, and proving it belongs in that walk rather than being claimed here.Verification
msbuild Scripts.csproj, 0 errors) — built offline with the shard stopped, because a running ServUO holdsScripts.dlland an in-place build failsMSB3027rather than reporting a compile error.0 Error(s)read each cycle to confirm the new code is live rather than trusting a clean boot.[bridge statusreportsidem(keys= seen= replayed= busy= evicted= uncorrelated=).Rig note, recorded in the scaffolding README:
deploy.ps1overwritesBridge.cfg, so every deploy stripsProtocol6ProbeOnStartandAdminWriteEnabled. The probe then silently does nothing on the next boot. The README already warned about this and it still cost a cycle.Sidecar: RunicGateway/link#36 · Module: RunicGateway/Module-uo#29 · Docs: RunicGateway/docs#219
🤖 Generated with Claude Code