diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 4e70515..3a054cd 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -69,6 +69,12 @@ Pin the version you built against and compare it to the header (or `/health.prot **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. +**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying +an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds +`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six +endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an +operator has not switched it on. See [`v6.md`](v6.md). + **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` / `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that @@ -970,6 +976,89 @@ human just pressed a button for and can see the result of. The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the second removal of an entry is a no-op the shard is already happy to perform. +### The event plane — leases (Protocol 6) + +**Off by default.** Every endpoint below answers **403** unless the operator has set +`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch: +enabling admin writes is consenting to staff moderation from a screen a human is looking at, and +enabling this is consenting to your world being changed and watched on a schedule, unattended. + +A **lease** is a live configuration value held at a new setting for a bounded time. The shard +restores the baseline when the deadline passes **whether or not you are ever heard from again** — +so the worst case is a world back at baseline early, never one stuck changed indefinitely. + +```json +GET /lease +→ 200 { "kind":"lease.list.ok", "leases":[ + { "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float", + "min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] } + +POST /lease +{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000, + "untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" } +→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 } + +POST /lease/release +{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" } +→ 200 { "kind":"lease.ok", "released":true, "current":"1000" } +``` + +Five things a caller needs: + +- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take + effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply + cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and + "any config key" is not offered. +- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline + is measured against two clocks, and a shard whose clock runs fast would restore your lease the + moment it took it. +- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed + values at the other end; the text is so a compare-and-set is comparing what you sent. +- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you + held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an + error: the mechanism did its job, and only a human can decide what should happen next. +- **`held` means the shard still has a record of the lease, not that the value is still overridden.** + A lease whose deadline has fired stays listed with `expired: true` until you release it, so a + reconcile in that window does not read a working backstop as a lost resource. A shard restart, by + contrast, reverts every lease and clears the record — `held: false` is how you learn that. + +### The event plane — participation (Protocol 6) + +A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed +by **character serial**. The shard computes a score and you store it; the components ride along so +you can explain it. + +```json +POST /participation +{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 } +→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false } + +POST /participation/99/snapshot +{ "idempotencyKey":"…" } +→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5, + "participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17", + "seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000", + "firstMs":1788550182074, "lastMs":1788553782074 } ] } + +POST /participation/99/close +→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 } +``` + +- **The area is a point and a radius, not a region name.** The most specific region containing an + event is routinely anonymous on a UO map — an active champion spawn registers a nameless region + over its own area — so a region-named venue would be undeclarable for exactly the places events + happen. +- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended + run the shard walks its members across game ticks rather than in one call, so a repeat arriving + mid-walk is answered **425**. Come back. +- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run + the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being + counted for it either way. +- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally + says so rather than quietly being short. +- **`webId`** is present only where the character's game account is linked to a website user. Most + characters carry none; treat its absence as ordinary. + ### Help-page (support) queue Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own @@ -1165,7 +1254,8 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's | 200 | OK | | 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | | 401 | Missing or invalid auth token | -| 404 | Not found (unknown account / character / id, or a not-linked account) | +| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard | +| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) | | 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | | 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | diff --git a/link/PLAN.md b/link/PLAN.md index 7c06674..c72498a 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -317,6 +317,41 @@ kind including ones a later protocol adds. A command with no key behaves exactly One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still in flight, nothing ran, come back. +### The event plane, and the Bridge's first persisted state (Protocol 6) + +```jsonc +{"kind":"lease.apply","key":"PlayerCaps.SkillCap","value":"1200","holdMs":600000,"runId":"77"} +{"kind":"participation.open","runId":"99","map":"Felucca","x":1496,"y":1628,"radius":40} +``` + +Six commands behind one new gate, `Bridge.EventsEnabled`, default off and deliberately not the admin +write plane's switch. Two facts about ServUO shaped both halves and are worth recording here rather +than only in the spec. + +**`Server/Config.cs` is a real runtime store, and almost nothing reads it live.** `Config.Set` +mutates the in-memory entry table and `Config.Load()` is guarded by `_Initialized`, so a Set survives +every later Get — but of the **158** non-Bridge `Config.Get` call sites in `Scripts/`, roughly +**eight** are read at the call site. The rest are cached at type initialisation, where a lease +applies cleanly and does nothing at all. That is why the lease catalog is a verified allowlist and +never "any config key". + +Two consequences, both deliberate: **nothing calls `Config.Save()`**, so a lease never reaches disk +and a shard restart is a free restore; and **`Config.Set` has exactly one caller in the whole tree** +(`Server/ScriptCompiler.cs`), so on a stock shard no GM can drift a configuration lease even +deliberately — the compare-and-set is still required for Phase 12's object-property leases, and +proving it needs the scaffolding driver. + +**The participation ledger is the first thing this plugin has ever persisted.** A run spans hours and +a restart mid-event is ordinary, so an in-memory tally would silently regress every attendee's score. +`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the +world save — no persistence *item*, so no world object and nothing for a GM to delete by accident. +The hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and +`Initialize()` is too late. + +**And the first handler that defers.** `participation.snapshot` walks a large run's members across +Core ticks rather than in one inbound call, which makes it the first handler to complete after +`OnInboundLine` returned — and therefore the first that can actually produce `bridge.busy`. + ### `server.hello` is per-connection, not per-boot The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. diff --git a/link/v6.md b/link/v6.md index 3b94aaf..24b6739 100644 --- a/link/v6.md +++ b/link/v6.md @@ -1,7 +1,7 @@ # Protocol 6 — A guarantee, and the kind that pays for it -**Status:** In review on `edge`. Part **a** of two: see §7 for what protocol 6 gains in 11b before it cuts over. -**Date:** 2026-09-04 +**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7. +**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b) **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11. @@ -175,6 +175,25 @@ means here specifically: **the sidecar makes no idempotency promise of its own.* does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which is the only place it can be — the shard is where the world write happens. +11b adds six routes and no new machinery. `event_call` is `admin_call` without the required +`actor`: an event verb's author is a **run**, which the body carries as `runId`, and demanding a +human name for something no human is doing would have the runner inventing one. + +| Route | Command | Note | +|---|---|---| +| `GET /lease` | `lease.list` | The whole catalog with current values. One read serves both `read()` and `inForce()` on the website's side | +| `POST /lease` | `lease.apply` | | +| `POST /lease/release` | `lease.release` | A drifted answer is a **200** — see §8.3 | +| `POST /participation` | `participation.open` | | +| `POST /participation/:runId/snapshot` | `participation.snapshot` | **A POST for a read**, because it carries the caller's `idempotencyKey` and can be refused as a repeat in flight. A read that can legitimately answer 425 is not a GET | +| `POST /participation/:runId/close` | `participation.close` | | + +`respond_event` is the fourth responder, and it exists for two mappings the generic one gets wrong: +a drifted lease is a 200 rather than a reason-sniffed 400, and the event plane being **switched off** +is a 403 rather than a 400 — `Bridge.EventsEnabled` is an operator's deliberate refusal, and telling +the website it sent a bad request would send an administrator hunting a bug in a step that is +written correctly. + ### 3.1 Why 425 and not 409 409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a @@ -206,6 +225,17 @@ ranked roll of who was strong enough to fell it. The kill is an event in the wor is a performance record of named players that nobody consented to publish. A shard that wants a public "who slew the champion" board lowers **one** field rule. +**11b's two frames are deliberately unmapped.** `lease.applied` and `lease.expired` are operational +records of the WEBSITE changing this shard's configuration — which key, from what to what, on whose +run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an +unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs; +mapping them would mean choosing a feature an operator could then widen, and there is no rung below +admin these belong on. Same reasoning as `account.login.result` in v5. + +The participation ledger emits no stream frame at all. A tally is read on request, not broadcast: +a live feed of who is standing where would be a location tracker, and the ledger's whole justification +is that it answers one bounded question about one run. + **The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount` and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into mail an operator may address to every subscriber, so a name reaching the trigger's data would undo @@ -221,7 +251,8 @@ touching the visibility config at all. | `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** | | `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders | | `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` | -| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` | +| `website` | **11b only.** `core.lease` gains a `reconcile()` and `registerEventLeases` gains an optional `inForce()` — see §8.4 | +| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` · `MODULE_API.md` (11b) | | `installer` | **nothing.** See below | | `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately | @@ -233,6 +264,11 @@ refuses a mismatch. The check is version-agnostic, so it needs no change for 6 e none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is released or bundled until the events cutover in any case. +**`EVENTS_PLAN.md` was wrong about the repo count in the other direction too.** Phase 11 is four +repos for 11a and **five** for 11b: a lease's ledger row had no reconcile path anywhere, because the +step that made it names `core.lease`, and that is core's own action rather than a module's. There is +nowhere on that path a module could hang an answer, so core had to grow one. §8.4. + **The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and `module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree* rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing. @@ -308,24 +344,285 @@ a consumer reading `region: ""` cannot tell "nowhere in particular" from "the sh --- -## 7. What 11b adds to protocol 6 +## 7. One version, two halves -Phase 11 is split. **This document covers 11a**; 11b adds lease deadlines and the run-scoped -participation ledger to the *same* protocol version, amended in place rather than bumped to 7 — -6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule is that -a protocol owes a bump once it has shipped and is amended in place before that. +Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping +to 7 — 6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule +is that a protocol owes a bump once it has shipped and is amended in place before that. Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle CI never sees either half until the cutover, by which time 6 means one thing. It would not be tolerable on `main`, and this paragraph exists so nobody discovers that the hard way. -What 11b will add here: +Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise. -- `lease.apply` / `lease.release` / `lease.list`, with a deadline the shard honours **without being - asked again** and a compare-and-set restore that reports `drifted` rather than overwriting a GM's - deliberate change. -- `participation.open` / `participation.snapshot` / `participation.close`, keyed by character serial - to match `module-uo`'s existing `memberKey`, and **persisted in the world save** — the Bridge's - first persisted state, so a tally survives a restart mid-event. -- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`. +--- + +## 8. The lease plane (11b) + +> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is +> held. +> +> — `EVENTS.md`, *Leases: the primitive underneath everything* + +Three commands, one new configuration gate, and two properties that are the whole reason the +framing is worth having. + +### 8.1 `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled` + +**Its own switch, default off** (org lead, 2026-09-04). 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. + +### 8.2 The commands + +```json +{"kind":"lease.apply","reqId":"r-7","key":"PlayerCaps.SkillCap","value":"1200", + "holdMs":600000,"untilMs":1788567000000,"runId":"77","idempotencyKey":"…"} +``` + +| Command | Answers | | +|---|---|---| +| `lease.list` | `lease.list.ok` | Every allowlisted key: `current`, `default`, `min`/`max`, and where held, `baseline` / `applied` / `untilMs` / `runId` / `expired` | +| `lease.apply` | `lease.ok` | `baseline`, `applied`, `untilMs` | +| `lease.release` | `lease.ok` or `lease.drifted` | Compare-and-set | + +Three shapes are worth stating because the obvious alternative is subtly wrong in each. + +**`holdMs` is authoritative and `untilMs` is for display.** An absolute deadline computed on the +website and honoured on the shard is a deadline measured against **two clocks**, and a shard running +ten minutes fast would restore a ten-minute lease the instant it took it. A duration is immune. The +absolute time still crosses, because a console that can say when the hold ends in terms the +operator's own clock agrees with is worth one field. + +**Values cross as TEXT, whatever the lease's declared type**, and comparison is done on the parsed +values. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a parser and two +different strings to a compare-and-set, and a drift check that compared formatted numbers would +report drift on a value nobody had touched — refusing to restore, leaving the world changed, and +blaming an innocent operator. + +**A lease held longer than the shard's ceiling is REFUSED, never clamped.** A clamp would quietly +give the website a shorter lease than it believes it has, and the website is the half that schedules +the restore; the two would then disagree about when the world comes back. `Bridge.LeaseMaxDurationSec` +is the shard's independent bound rather than a mirror of core's — it exists for the case where the +website is wrong, and being loud about it is the entire value. + +### 8.3 The two mechanisms, and one thing a stock shard cannot do + +**The deadline lives on the shard.** A lease arms a timer, and when it passes the shard restores +baseline **whether or not the website is ever heard from again**. Core drives the normal restore; +this is the backstop. It inverts the naive design, where restoration depends on core dispatching a +cleanup step and therefore fails *open* if core dies mid-event. A lease fails *safe*, and the worst +case is a world back at baseline early rather than one stuck changed indefinitely. The shard emits +`lease.expired` so the website learns what happened without being asked. + +**Restore is compare-and-set, never a blind write.** If the current value is not what the event +applied, somebody moved it deliberately: answer `lease.drifted` with the current value, leave the +world alone, and let an operator decide. A **200**, not a 409 — the shard did exactly what it was +asked, and 409 is the version gate's with the opposite disposition. Blindly restoring would silently +revert a staff member's change, which is the one failure that would make operators distrust the +whole feature. + +> **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`, +> for `Compiler.Dynamic`. There is no in-game command, no gump and no console verb that writes a +> config key. + +So on a stock shard a GM **cannot drift a configuration lease even deliberately**. The mechanism is +still correct and still required — Phase 12's object-property leases are trivially driftable, and a +shard with custom scripts may well write config at runtime — but proving it needs the `configset` +verb in `tools/scaffolding/BridgeRigDriver.cs`, which exists for exactly that reason. + +**A lease is memory-only, and that is a decision.** `Config.Set` mutates the in-memory entry table; +`Config.Load()` is guarded by `_Initialized` and so runs once at boot, which is what makes a Set +survive every later Get. **Nothing ever calls `Config.Save()`**, so a shard restart is a *free* +restore — the strongest fail-safe available, at no cost. It is also why `lease.list` reports an empty +hand after a restart, which is precisely what lets the website's reconcile notice the lease is gone. + +A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's original +default marker too, because the entry compares against the value it was loaded with. Restoring a key +that was `@`-defaulted in a cfg file leaves it `@`-defaulted. + +### 8.4 The reconcile hole, and `inForce()` + +**A lease's ledger row had no reconcile path at all, and nothing failed to say so.** `cleanup.js` +resolves a resource to the action of the step that made it, and for a lease that action is +`core.lease` — a **core** action, on a path a module cannot register anything on. So every `override` +row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped stayed +in the ledger as live until teardown went looking for a baseline nobody was holding. + +11b closes it in two pieces, both in `website`: + +- `core.lease` gains a `reconcile()`. +- `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any + record of this hold?"* + +It is deliberately not `read()` plus a comparison. A value that differs from what the run applied is +**drift**, which teardown must deliver through `restore()` so the row lands `drifted` with the +current value beside it; a reconcile that inferred absence from a changed value would orphan the row +first and destroy that signal — telling the operator the lease vanished rather than that somebody +moved it. Only an explicit `{ ok: true, held: false }` takes a row out; a throw, a timeout, an +unrecognised shape and a lease with no `inForce()` all leave it alone. + +`MODULE_API_VERSION` stays **1.10.0**, amended in place, by the same rule §7 states for the protocol. + +### 8.5 The catalog is short, and shorter than `EVENTS.md` expected + +§D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type +initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes +effect at once. Measured on 57.4 the split is not near even: of the **158** non-Bridge call sites in +`Scripts/`, roughly **eight** are live reads. + +11b ships **one** key: `PlayerCaps.SkillCap`, read live inside `CharacterCreation.cs`'s per-character +path and divided by ten to give the per-skill cap. It is both live and observable, which is what +"proven" has to mean here — the failure the allowlist exists to prevent is a key that applies +cleanly and changes nothing at all. Phase 12 adds the rest, with the boot-time self-check that drops +a key from the advertised catalog if it does not take. + +--- + +## 9. The participation ledger (11b) + +§G rates participation attribution as the largest remaining piece of new UO work, and says why +nothing composed out of the existing streams stands in for it: `region.enter` plus `mob.killed` is +loosely composable and **not trustworthy enough to publish results on**. Nothing scopes a kill or an +arrival to a run, nothing separates a passer-by from an attendee, and nothing survives a relog. + +| Command | Answers | | +|---|---|---| +| `participation.open` | `participation.ok` | `{ runId, map, x, y, radius, holdMs }` | +| `participation.snapshot` | `participation.snapshot.ok` | The tally, resolved to names and accounts | +| `participation.close` | `participation.ok` | Stops counting; the tally stays readable through the grace window | + +**The area is a map, a point and a radius** (org lead, 2026-09-04). Not a region name: §6.1 above +established that the most specific region containing an event is routinely **anonymous**, so a +region-named area would be undeclarable for exactly the venues events use. Not a rectangle either — +an author picks the spot the event happens at, not two opposite corners of it. + +**Members are keyed by character serial**, matching `module-uo`'s existing Teams `memberKey`, so one +module speaks one member vocabulary and a participant joins to a roster without a translation table. +A player who attends on two characters is two members, which is the answer Teams already gives. + +**The shard computes the score and core stores an opaque number it never interprets.** *"A minute +present plus five a kill"* is a sentence about Ultima Online, and the sentence has to live on the +Ultima Online side of the seam. The components ride along in the frame anyway, because a results +table that can say "forty minutes and three kills" beside a number is one an operator can defend +when a player argues with it. + +Two shapes that look like details and are not: + +- **Presence accrues in SECONDS, not sample counts.** A count would have to be multiplied by the + sweep interval to mean anything, and the interval is a config key an operator may change halfway + through a five-hour run — silently rewriting the first half of the tally. The kill weight is frozen + per run at open for the same reason. +- **Kill credit goes to every damager standing in the area, not to the killer.** A last hit is a poor + description of who fought something: the player who held it for four minutes and died to it took + part more than the one who landed the blow that finished it. The area test is applied to the + **damager**, so someone shooting in from outside is not attending and someone who has since walked + away accrues nothing more. Entries are summed per damager for the reason `champ.boss.killed`'s + table is (§2.2): an expired-and-recreated entry leaves two. + +### 9.1 The Bridge's first persisted state + +Nothing in this bridge has ever persisted anything. A ledger has to: a run spans hours and a restart +mid-event is an ordinary Tuesday, and an in-memory tally would silently regress every attendee's +score to whatever they earned after the restart. The only ways to paper over that from the other side +are a high-water rule in core — which must stay game-agnostic and cannot have one — or a per-run +offset in the module, which is the same bug with more moving parts. + +`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the +world save, rather than a persistence **item**: no world object, no serial, nothing for a GM to find +and delete by accident, and a wipe of custom items leaves the ledger intact. The save and load hooks +are attached **unconditionally**, before the enabled gate is consulted — an operator who switches the +plane off for an afternoon must not come back to a truncated file where a run's tally used to be. + +Bounds, all `Bridge.*` keys: eight runs counted at once, 2000 members per run, a 300-tile ceiling on +the area, and a 24-hour grace window after a run closes. A member the cap turns away is **counted** +and the count rides on every snapshot: a truncated tally that says it is truncated is usable, and one +that does not is a leaderboard with people missing from it for no stated reason. + +### 9.2 The first handler that defers + +`participation.snapshot` resolves every member serial to a mobile and an account, so a well-attended +run is hundreds of world lookups in one inbound call — exactly the work the Core thread must not be +handed in one piece. Above `Bridge.ParticipationSnapshotChunk` members it walks in chunks across +ticks, using `BridgeIdempotency.Hold` / `Complete`. + +That makes it the first handler in the bridge to complete **after** its inbound call returns, and +therefore the first that can genuinely answer `bridge.busy`. 11a built that door and had nothing to +walk through it. + +--- + +## 10. Verification (11b) + +Unit tests: **588** in `module-uo/server` (17 new), **42** in `module-uo/client`, **2003** in +`website/server` (5 new), **47** in the sidecar (4 new). `cargo fmt`, `cargo clippy -D warnings` and +`check:imports` clean; the C# compiles against the real ServUO 57.4 reference assemblies with the +shard stopped. + +The rig: a real ServUO with a seeded world, the **release** Rust sidecar, and +`tools/scaffolding/BridgeRigDriver.cs` (`configset` / `configread` / `partprobe`) plus +`BridgeParticipationProbe.cs`. + +| Claim | Evidence | +|---|---| +| a lease applies, and the change is visible through the shard's own reader | `lease.apply` → `{"baseline":"1000","applied":"1200"}`; an independent `Config.Get` from a different class, long after every type initialiser, read `1200` | +| **the shard restores baseline with nobody asking** | A 20-second lease, and then silence. `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, and `lease.expired` on the feed with `restored: true` | +| an expired lease still yields a verdict to teardown | `lease.list` reported `held: true, expired: true, restored: true`; the later `lease.release` answered `alreadyRestored: true` rather than an error | +| **a mid-lease GM edit produces `drifted`, and the world is LEFT ALONE** | `configset PlayerCaps.SkillCap 1350` under a live lease → `lease.release` answered `{"kind":"lease.drifted","current":"1350"}` at **200**, and a read afterwards still showed `1350` | +| a restart reverts a config lease, and the catalog says so | After a restart: `held: false`, `current: "1000"` — which is what makes `inForce()` correct | +| kill credit is per damager, inside the area | Two seeded players damaging one creature at the venue: both credited `kills: 1`, `score: 5.0` | +| **the tally survives a shard restart mid-run** | `save`, `shutdown`, boot: `[Bridge] participation: 1 run(s) restored from the world save`, and the snapshot returned both members with identical `firstMs` | +| **the first live `bridge.busy`** | Two concurrent `POST /participation/:runId/snapshot` under one key, chunk size 1: the first answered 200 with the tally, the second **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` | +| a deferred key replays like any other | A third request under the same key answered `replayed: true` under its own `reqId` and the **first attempt's** `t` | + +### 10.1 The defect the deferral found, in 11a's own code + +`bridge.busy` came back **200**, not 425, the first time anything produced it. + +`BridgeIdempotency.Busy` built its frame with `BridgeJson.Begin("bridge.busy")` — which writes +`"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)` naming the command +that was in flight. **The object carried two `kind` fields, and every JSON parser worth the name +takes the last.** The sidecar matched on `bridge.busy` to decide the 425, read +`participation.snapshot` instead, and answered an ordinary 200 with a body saying nothing had +happened — the single worst of the three possible answers, since a retry loop would treat it as +success. + +It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never +arrive mid-flight, so the arm was unreachable on a live shard, and the sidecar test that covers the +mapping was — correctly — feeding it a frame built by hand. The first deferring handler produced it +on its first collision. Renamed to `busyKind`. + +This is the argument for the phase's ordering, stated as a fact rather than a hope: 11a said +*"11b's leases are the first thing that can actually produce it, and proving it belongs in that +walk"*, and the walk found a real bug in shipped code. + +### 10.2 What the rig could not drive: presence + +The participation ledger counts two things and only one of them is reachable headlessly. + +**Presence needs a connected client.** 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, and a ledger that said otherwise would put people at the top +of a leaderboard for being AFK. There is no way to produce a NetState short of writing a client, and +ClassicUO cannot be driven from this machine. + +**Kill credit needs none**, so the whole of the credit path — the damager filter, the per-damager +fold, the area test on the damager, the member cap — ran exactly as it would in a fight, and the +accrual, persistence, snapshot, chunking and replay paths were driven by it. The one line the walk +did not exercise is `member.Seconds += seconds`, and it is named here rather than left to be assumed. + +### 10.3 Two rig traps + +- **`Core.Kill` does not save the world.** The rig driver's `shutdown` verb is a clean shutdown, which + is the only kind that *emits* — and it emits `server.shutdown` without writing a save. The first + restart test therefore reloaded an **empty** `Participation.bin` and looked exactly like a + persistence bug. `save` then `shutdown` is the sequence; the file's length is the check. +- **A probe that means to produce two damagers must not kill with the first blow.** An opening 40 + damage on a Mongbat (around thirty hit points) killed it where it stood, so the second damager never + landed a hit, `DamageEntries` held one name, and the ledger correctly credited one player. It read + as a plugin crediting only the killer. Scaled to the creature's `HitsMax`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 4f8079e..eb9c0b0 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -205,6 +205,18 @@ live-read — never "any config key". A module must not advertise a lease it can because *"the setting applied and nothing happened"* is the worst failure this feature has. That is a testable obligation, and the test is mechanical: for each key in the catalog, apply, observe, restore. +> **Measured in Phase 11b, and it is far more lopsided than "two patterns" suggests.** Of the 158 +> non-Bridge `Config.Get` call sites in `Scripts/` on ServUO 57.4, roughly **eight** are read live. +> The rest are cached at type initialisation. So the allowlist is not a curated subset of a large +> pool — it is nearly the whole of what is available, and the catalog Phase 12 inherits will be +> short for reasons no amount of care can change. +> +> A second measurement, which decides how `drifted` gets tested at all: **`Config.Set` has exactly +> one caller in the whole tree** (`Server/ScriptCompiler.cs`). 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 the config half of it is proved with scaffolding, not by a GM. + Beyond configuration the same pattern covers any per-object property whose current value is readable before it is written — an existing spawner's `Amount` / `MinDelay` / `MaxDelay`, a named creature's stats, a `SeasonalEventSystem` entry's status. Loot stays excluded for the reason it always was: it @@ -807,6 +819,10 @@ api.registerEventLeases([{ // not an error: answer { ok: false, drifted: true, current }. return { ok: true } }, + // Optional (Phase 11b). "Does the game side still have any record of this + // hold?" — a DIFFERENT question from `read`, and the only thing that takes a + // lease's ledger row out at reconcile. + async inForce() { return { ok: true, held: true } }, }]) ``` @@ -829,6 +845,16 @@ api.registerEventLeases([{ renders. A dimension an action prices but nobody declares is **shown** on that screen rather than filtered out, because the action is refused and the operator needs to be told which module is incomplete. +- **`inForce()` is the fourth, it is optional, and it is not `read()` with a comparison** + (Phase 11b). It answers *"does the game side still have any record of this hold?"*, which none of + the other three do — and it had to exist because a lease's ledger row has no reconcile path + otherwise: the step that made it names `core.lease`, which is core's own action, so there is + nowhere a module could hang the answer. A value that DIFFERS from what the run applied is drift, + which `restore()` reports so the row lands `drifted` with the current value beside it; inferring + absence from a changed value would orphan the row first and destroy that signal. Only an explicit + `{ ok: true, held: false }` takes a row out; a throw, a timeout and a lease with no `inForce()` + all leave the ledger alone. It matters most for exactly the case core could not see before: a + config lease is memory-only on the shard, so a restart reverts it *and* clears the record. - **A lease declares all three callables, and `restore` is not optional even though `read` could stand in for it.** They answer different questions: `read` is *"what is it now"*, `restore` is *"put this back, and tell me if someone else has moved it"* — the drift check, which is the one @@ -1095,13 +1121,13 @@ a capability exists. | Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. | | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | | Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. | -| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. | +| **Participation attribution** | ✅ built | Protocol 6 part b. Presence in a declared area plus kill credit inside it, keyed by character serial, **persisted in the world save** so a restart mid-event does not lose it. The area is a map, a point and a radius rather than a region name — the most specific region containing an event is routinely anonymous. Kill credit goes to every damager standing in the area, not to the killer: a last hit is a poor description of who fought something. | | Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | | Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | | Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. | | Named, hued creatures from an allowlist | 🔧 📡 | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. | | "Simple" boss variants | 🔧 📡 | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. | -| **Lease a live config value** — rates, toggles, caps | 🔧 📡 | `Server/Config.cs` is a runtime typed key-value store, so this works — **for keys read live**. The plugin ships a verified allowlist, because a lease on one of the `static readonly` keys applies cleanly and does nothing. The single most transferable action in the whole set. | +| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. Phase 12 adds the rest with the boot-time self-check. | | Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. | | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | | Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | @@ -1973,10 +1999,13 @@ the platform's largest missing safety property. Today the moderation write plane happened and nothing about what it produced. It is also what makes the reward audit answerable — who received what, from which step, in which run. -**Participation attribution is now the largest piece of new UO work.** -With points cut, spawning is no longer the hard part — attribution is. Results, profile history, the +**Participation attribution was the largest piece of new UO work.** ✅ *Built in Phase 11b.* +With points cut, spawning was never the hard part — attribution was. Results, profile history, the calendar's "what happened" and any future recognition all rest on trustworthy "who took part", and UO -gives no run-scoped attribution that can be synthesised from the website side. +gives no run-scoped attribution that can be synthesised from the website side. So the shard counts it: +presence in a declared area plus kill credit inside it, keyed by character serial, and **persisted in +the world save** — which made it 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. **The GM cue step makes the system useful before any protocol change.** "Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index acc2cd8..a4518c6 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1358,6 +1358,66 @@ deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes reachable. +> **Built.** All four verifications passed on a real ServUO with the release sidecar. Protocol 6 +> amended in place; `MODULE_API_VERSION` amended in place at 1.10.0. See +> [`../link/v6.md`](../link/v6.md) §§7–10. +> +> **It is FIVE repos, not four, and the plan was wrong about this in the opposite direction from +> 11a.** A lease's ledger row had no reconcile path anywhere, and nothing failed to say so: +> `cleanup.js` resolves a resource to the action of the step that made it, and for a lease that +> action is `core.lease` — a CORE action, on a path a module cannot register anything on. So every +> `override` row came back `unanswered` for the life of the run, and a lease the shard had quietly +> dropped stayed in the ledger as live until teardown went hunting a baseline nobody was holding. +> `website` joins the phase: `core.lease` gains a `reconcile()` and `registerEventLeases` gains an +> optional **`inForce()`**. Deliberately not `read()` plus a comparison — a changed value is DRIFT, +> which teardown must report so the row lands `drifted`, and inferring absence from it would orphan +> the row first and tell the operator the lease vanished rather than that somebody moved it. +> +> **Ten decisions (org lead, 2026-09-04), all as recommended.** The five-repo correction and +> `inForce()`; `PlayerCaps.SkillCap` as the one proven key; a scaffolding write verb to make +> `drifted` reachable at all; leases memory-only, so a restart is a free restore; a separate +> `Bridge.EventsEnabled` gate rather than `AdminWriteEnabled`; map + point + radius for the area; +> presence-plus-weighted-kills for the score; the shard-side bounds and grace window; chunking +> `participation.snapshot` as the thing that defers; and no `MODULE_API` bump. +> +> **The catalog is far shorter than §D expected, and the measurement is the finding.** §D frames the +> 258 `Config.Get` call sites as splitting into two patterns. Measured: of the **158** non-Bridge +> sites in `Scripts/`, roughly **eight** are read live. The allowlist is not a curated subset of a +> large pool — it is nearly the whole of what exists. And **`Config.Set` has exactly one caller in +> the entire tree** (`Server/ScriptCompiler.cs`), so no in-game command, gump or console verb writes +> a config key: on a stock shard a GM cannot drift a *configuration* lease even deliberately, which +> is why proving `drifted` needed a `configset` verb in the rig driver. +> +> **The walk found a defect in 11a's shipped code, which is the argument for the ordering.** +> `bridge.busy` came back **200**, not 425, the first time anything produced it: +> `BridgeIdempotency.Busy` built its frame with `Begin("bridge.busy")` and then appended a diagnostic +> `.Str("kind", prior.Kind)`, so the object carried **two `kind` fields** and every JSON parser takes +> the last. The sidecar read `participation.snapshot`, matched nothing, and answered a 200 whose body +> said nothing had happened — the worst of the three possible answers, because a retry loop reads it +> as success. Unreachable in 11a by construction, and the first deferring handler produced it on its +> first collision. Renamed `busyKind`. +> +> **One resource in `module-uo` must NOT reconcile by boot stamp, and it is this one.** Every other +> resource wave 1 ships is stamped with the shard boot that created it, because a crier line and a +> news article live in shard memory and a restart is definitionally the loss of both. The +> participation ledger is written into the world save *specifically* so it survives a restart, so +> the stamp would orphan the one resource the phase went to the trouble of persisting. It asks +> instead, and only a 404 takes a row out. +> +> **What the rig could not drive: 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 the credit path, the accrual, the persistence, the +> chunking and the replay were all driven; the one unexercised line is the presence accrual itself, +> and it is named rather than assumed. +> +> **Two rig traps, both of which faked a defect.** `Core.Kill` does **not** save the world, so the +> first restart test reloaded an empty `Participation.bin` and looked exactly like a persistence bug +> — `save` then `shutdown` is the sequence. And a probe that means 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. + --- ### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index a3452d1..4fd9c63 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -114,6 +114,7 @@ api.registerEventLeases([{ async read() { return { ok: true, value: 1.0 } }, async apply(v, until) { return { ok: true } }, async restore(baseline, { expected }) { return { ok: true } }, + async inForce() { return { ok: true, held: true } }, // optional }]) ``` @@ -129,6 +130,17 @@ field: showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that means "this will never work" says `retry: false`. +- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering + `{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything + else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone. + Core needs it because a reconcile after an outage asks *"does the game side still have any record + of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run + applied is drift, which `restore()` reports so the row lands `drifted` with the current value + beside it, and a reconcile that inferred absence from a changed value would orphan the row first + and tell the operator the lease vanished rather than that somebody moved it. The two questions + have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's + step names `core.lease`, which is core's own action, so there is nowhere else a module could hang + the answer. - **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same @@ -652,7 +664,7 @@ api.registerAudiences([{ id, label, params, ceiling, resolve }]) // api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0 api.registerEventBudgets([{ id, label, unit }]) // 1.10.0 -api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }]) // 1.10.0 +api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {})