docs(events): the Event System record — the cutover step 16b missed (edge → main)
#232
@@ -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.
|
**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` /
|
**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
|
`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
|
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
|
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.
|
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
|
### Help-page (support) queue
|
||||||
|
|
||||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
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 |
|
| 200 | OK |
|
||||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
||||||
| 401 | Missing or invalid auth token |
|
| 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` |
|
| 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 |
|
| 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` |
|
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||||||
|
|||||||
35
link/PLAN.md
35
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
|
One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still
|
||||||
in flight, nothing ran, come back.
|
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
|
### `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.
|
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.
|
||||||
|
|||||||
329
link/v6.md
329
link/v6.md
@@ -1,7 +1,7 @@
|
|||||||
# Protocol 6 — A guarantee, and the kind that pays for it
|
# 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.
|
**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7.
|
||||||
**Date:** 2026-09-04
|
**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b)
|
||||||
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
|
**Codebase:** ServUO 57.4, `<servuo>`, 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).
|
**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.
|
**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
|
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.
|
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
|
### 3.1 Why 425 and not 409
|
||||||
|
|
||||||
409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a
|
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
|
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.
|
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`
|
**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
|
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
|
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** |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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 |
|
| `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
|
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.
|
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
|
**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*
|
`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.
|
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
|
Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping
|
||||||
participation ledger to the *same* protocol version, amended in place rather than bumped to 7 —
|
to 7 — 6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule
|
||||||
6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule is that
|
is that a protocol owes a bump once it has shipped and is amended in place before 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
|
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
|
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
|
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.
|
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.
|
## 8. The lease plane (11b)
|
||||||
- `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
|
> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is
|
||||||
first persisted state, so a tally survives a restart mid-event.
|
> held.
|
||||||
- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`.
|
>
|
||||||
|
> — `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`.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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
|
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 }.
|
// not an error: answer { ok: false, drifted: true, current }.
|
||||||
return { ok: true }
|
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
|
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
|
filtered out, because the action is refused and the operator needs to be told which module is
|
||||||
incomplete.
|
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
|
- **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
|
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
|
*"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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 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. |
|
| 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. |
|
| 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. |
|
| "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. |
|
| 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. |
|
| 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. |
|
| 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
|
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.
|
received what, from which step, in which run.
|
||||||
|
|
||||||
**Participation attribution is now the largest piece of new UO work.**
|
**Participation attribution was the largest piece of new UO work.** ✅ *Built in Phase 11b.*
|
||||||
With points cut, spawning is no longer the hard part — attribution is. Results, profile history, the
|
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
|
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.**
|
**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
|
"Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world
|
||||||
|
|||||||
@@ -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
|
a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes
|
||||||
reachable.
|
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`)
|
### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`)
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ api.registerEventLeases([{
|
|||||||
async read() { return { ok: true, value: 1.0 } },
|
async read() { return { ok: true, value: 1.0 } },
|
||||||
async apply(v, until) { return { ok: true } },
|
async apply(v, until) { return { ok: true } },
|
||||||
async restore(baseline, { expected }) { 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
|
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
|
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`.
|
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
|
- **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
|
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
|
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.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
|
||||||
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
|
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
|
||||||
api.registerEventBudgets([{ id, label, unit }]) // 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.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0
|
||||||
api.onBoot(async (ctx) => {})
|
api.onBoot(async (ctx) => {})
|
||||||
api.onShutdown(async () => {})
|
api.onShutdown(async () => {})
|
||||||
|
|||||||
Reference in New Issue
Block a user