docs(link): protocol 6 part b -- leases, participation, and what the walk found
Six files. `v6.md` gains sections 7-10: one version with two halves, the lease plane, the participation ledger, and 11b's verification. Three corrections to what the plan and the design of record assumed. Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's correction: 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. `website` joins. EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two patterns. Measured on 57.4: 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, so on a stock shard a GM cannot drift a configuration lease even deliberately -- which is why proving `drifted` needed a scaffolding verb. §G's "participation attribution is now the largest piece of new UO work" closes, and the live-config-lease row goes to built-with-one-key. §10.1 records the defect the phase's own deferral found in 11a's shipped code: `bridge.busy` answered 200 instead of 425 because the frame carried two `kind` fields and parsers take the last. Unreachable in 11a by construction; produced on the first collision here. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 () => {})
|
||||
|
||||
Reference in New Issue
Block a user