feat(bridge): lease deadlines and the participation ledger (Phase 11b) #22
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/events-p11b-leases-participation"
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 11b, the plugin half. Protocol 6 amended in place —overlay.tomlstays6, and §7 ofv6.mdsays why that is safe onedgeand would not be onmain.Two mechanisms, one new gate, and the phase's own deferral found a bug in 11a.
Bridge.EventsEnabled, and why it is notAdminWriteEnabledIts own switch, default off (org lead). Enabling the admin plane is an operator consenting to staff moderation driven from the website — a human pressing kick or ban on a screen they are looking at. A lease and a participation ledger are the website changing and watching the world on a schedule, unattended, at four in the morning. Those are different consents and one switch cannot honestly express both; an operator who wanted the first and got the second would be right to be angry.
Leases
lease.apply/lease.release/lease.list. The two mechanismsEVENTS.mdnames are the whole of the file:drifted, leave the world alone. Blindly restoring would silently revert a staff member's change, which is the one failure that would make operators distrust the feature.Three shapes where the obvious alternative is subtly wrong:
holdMsis authoritative,untilMsis display. An absolute deadline is measured against two clocks, and a shard running ten minutes fast would restore a ten-minute lease the instant it took it.1200and1200.0are one number to a JSON parser and two strings to a diff, and a drift check comparing formatted numbers would report drift on a value nobody touched — refusing to restore, leaving the world changed, and blaming an innocent operator.Two measurements that shaped the catalog
EVENTS.md§D frames it as two patterns; it is nearer 95/5. The allowlist is not a curated subset of a large pool — it is nearly the whole of what exists. 11b shipsPlayerCaps.SkillCap, read live inCharacterCreation.cs, which is both live and observable.No in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately. The compare-and-set is still required — Phase 12's object-property leases are trivially driftable — but proving it needed the new
configsetverb intools/scaffolding/BridgeRigDriver.cs.Memory-only, and that is a decision. Nothing calls
Config.Save(), so a restart is a free restore — and it is also whylease.listreports an empty hand afterwards, which is exactly what lets the website's reconcile notice the lease is gone.Participation
participation.open/.snapshot/.close. Presence in a declared area plus kill credit inside it, keyed by character serial to match this org's TeamsmemberKey.The Bridge's first persisted state
Nothing here has ever persisted anything. A ledger has to: a run spans hours and a restart mid-event is an ordinary Tuesday.
Server.Persistence+EventSink.WorldSavewrites a companion file rather than a persistence item — no world object, nothing for a GM to delete by accident. Hooks attach inConfigure(), becauseEventSink.WorldLoadfires insideWorld.Load()andInitialize()is too late. They attach unconditionally, before the gate: an operator who switches the plane off for an afternoon must not come back to a truncated file.The defect the deferral found, in 11a's own code
participation.snapshotchunks its walk across Core ticks, which makes it the first handler to complete afterOnInboundLinereturned — and the first that can producebridge.busy. On its first collision it answered 200, not 425.BridgeIdempotency.Busybuilt its frame withBegin("bridge.busy")— which writes"kind":"bridge.busy"— and then appended a diagnostic.Str("kind", prior.Kind). The object carried twokindfields, and every JSON parser takes the last. The sidecar readparticipation.snapshot, matched nothing, and returned an ordinary 200 whose body said nothing had happened — the worst of the three possible answers, because a retry loop reads it as success.It shipped in 11a and could not be seen there: with only synchronous handlers a repeat can never arrive mid-flight, and the sidecar test covering the mapping was correctly feeding it a hand-built frame. Renamed
busyKind. This is 11a's own sentence — "11b's leases are the first thing that can actually produce it, and proving it belongs in that walk" — collected.Verification
Compiles clean against the real ServUO 57.4 reference assemblies (
msbuild Scripts.csproj, 0 errors), built offline with the shard stopped — a running ServUO holdsScripts.dlland failsMSB3027rather than reporting a compile error.The live rig, all four of the phase's verifications:
[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked, pluslease.expiredon the feedlease.listreportedheld: true, expired: true, restored: true; the later release answeredalreadyRestored: truedriftedconfigset … 1350under a live lease →{"kind":"lease.drifted","current":"1350"}, and a read afterwards still showed1350. The world was left aloneheld: false,current: "1000"— which is what makes the website'sinForce()correctkills: 1,score: 5.0[Bridge] participation: 1 run(s) restored from the world save, both members with identicalfirstMsbridge.busy{"kind":"bridge.busy","busyKind":"participation.snapshot"}replayed: trueunder its ownreqIdand the first attempt'stWhat the rig could not drive, and two traps that faked defects
Presence. The sweep credits online players (
NetState != null), which is the correct test and not one a probe should loosen — a character parked in Britain and logged out for eight hours did not attend anything. There is no way to produce a NetState short of writing a client. Kill credit needs none, so accrual, persistence, chunking and replay were all driven; the one unexercised line ismember.Seconds += seconds, named here rather than assumed.Core.Killdoes not save the world. The rig driver'sshutdownis a clean shutdown, which is the only kind that emits — and it writes no save. The first restart test reloaded an emptyParticipation.binand looked exactly like a persistence bug.savethenshutdown.A probe meaning to produce two damagers must not kill with the first blow. 40 damage on a Mongbat killed it where it stood, the second damager never landed a hit, and the ledger correctly credited one player while reading as a plugin that credits only the killer. Scaled to
HitsMax.AI-assisted: Claude Code (Opus 5).
Sidecar: RunicGateway/link#37 · Core: RunicGateway/website#193 · Module: RunicGateway/Module-uo#30 · Docs: RunicGateway/docs#220
🤖 Generated with Claude Code
Protocol 6 amended in place. Two mechanisms behind one new default-off gate, `Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling the admin plane is consenting to staff moderation from a screen a human is looking at, and this is consenting to the world being changed and watched on a schedule, unattended. BridgeLeases: a live config value held for a bounded time, with the deadline honoured on the shard whether or not the website is heard from again, and a compare-and-set restore that reports `drifted` rather than overwriting a GM's deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is a free restore. BridgeParticipation: presence in a declared area plus kill credit inside it, keyed by character serial, persisted in the world save. The Bridge's first persisted state, because a run spans hours and an in-memory tally would regress every attendee's score after one restart. Its snapshot is also the first handler that DEFERS, which makes `bridge.busy` reachable for the first time. And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so the object carried two `kind` fields and every JSON parser takes the last. The sidecar answered 200 instead of 425. Renamed `busyKind`. Co-Authored-By: Claude <noreply@anthropic.com>