diff --git a/link/v7.md b/link/v7.md index bf8e800..1993a94 100644 --- a/link/v7.md +++ b/link/v7.md @@ -1,7 +1,7 @@ # Protocol 7 — What an event owns **Status:** In review on `edge`. **12a and 12b land as one protocol version** — see §8. -**Date:** 2026-09-07 (12a) +**Date:** 2026-09-07 (12a and 12b) **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), [`v6.md`](v6.md) (6.0 idempotency, leases, participation), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 12. @@ -248,8 +248,8 @@ The three declaration sites, all bumped in 12a: `link/sidecar/src/main.rs` (`PROTOCOL_VERSION`), `servuo-plugins/overlay.toml` (`protocol`), and the website's admin-managed `uoLinkConfig`. `installer` has none, for the reason Phase 11 records. -**12b adds** object-property leases and their boot-time self-check, the seasonal-event toggle, a -world save, and the item grant. +**12b amends 7 in place** with object-property leases and their boot-time self-check, the +seasonal-event toggle, a world save and the item grant. It is specified in §11 to §14 below. --- @@ -262,8 +262,27 @@ world save, and the item grant. | `module-uo` | Five actions, five budget dimensions, the decoration option source and the atlas index behind it | | `docs` | This file; `EVENTS.md` §G; `EVENTS_PLAN.md` Phase 12 | -`MODULE_API_VERSION` does **not** move. Budget dimensions and option sources are things a module -*declares* through an API that already exists; nothing in the contract core reads has changed. +**Part b adds to every row of it, and adds a fifth.** `servuo-plugins`: `BridgeLeaseTargets.cs`, +`BridgeOneShots.cs`, the persisted `Leases.bin` and the boot self-check. `link`: `target` on the +lease family, `GET /items`, `POST /items/grant`, `POST /world/save`. `module-uo`: five targeted +leases, two actions, `uo.rewards`, three option sources, the atlas's `unique_id`, **and the +protocol pin 11a and 12a both missed** (§14). `docs`: this file, `EVENTS.md` §D/§G, +`MODULE_API.md`. + +**`website` is in part b and was not in part a.** A targeted lease is a shape `core.lease` did not +have: the declaration gains a `target` and a `values` set, the four callables gain the target, and +the reservation ref becomes `#` so the two-events-one-target index bites at the +granularity the world actually has. Extending core rather than giving the module its own lease verb +is what `EVENTS.md` §F decided in Phase 8 — *"the verb is core's"* — and the alternative would have +re-implemented `maxDurationMs` per module, advisory everywhere and wrong in the first one that +forgot. + +`MODULE_API_VERSION` does **not** move — in part a because budget dimensions and option sources are +things a module *declares* through an API that already exists, and in part b because **1.10.0 is +amended in place**, the shape every phase since P10 has used while the workstream sits on `edge`. +Part b really does change the contract (targeted leases, lease value sets, searchable option +sources); nothing is released from `edge`, so the version is amended rather than bumped, and the +cutover is what publishes it. --- @@ -293,3 +312,260 @@ the shard can build, because a creature is constructed from a ServUO class name not one. The atlas's `name` **is** the raw type token from the spawn files, so the fix was to stop discarding the half that works. Safe to change because 12a is the source's first consumer — the file said so when it shipped. + +--- + +## 11. Part b — what an event BORROWS + +12a was the first half of §G's rule. This is the second: **an event may borrow what it changes, and a +borrowed value carries a deadline the game itself enforces.** + +Two planes, and both of them are **targeted** — a lease names a key *and* the thing it applies to. +Every lease before protocol 7 named a single value (a config key, a rate), so the key *was* the +target. `Spawner.MaxCount` is not that: it is one capability over thousands of spawners, and two runs +turning up two different spawners must both be allowed while two runs turning up the same one must +not. + +| Plane | Key | Target | Holds | +|---|---|---|---| +| Object property | `Spawner.MaxCount` | a spawner | how many it keeps alive | +| Object property | `Spawner.MinDelay` / `Spawner.MaxDelay` | a spawner | its respawn window, in seconds | +| Object property | `Spawner.Running` | a spawner | whether it runs at all | +| Seasonal | `Seasonal.Status` | an `EventType` | `Inactive` / `Active` / `Seasonal` | + +The wire is the **same `lease.apply` / `lease.release` / `lease.list` family** protocol 6 introduced, +with a `target` field. One deadline, one compare-and-set, one grace window, one set of counters — +three planes rather than three protocols. + +### 11.1 A lease here must be PERSISTED, and the config plane's must not + +[`v6.md`](v6.md) states the config lease's fail-safe plainly: *a lease that never reaches disk means +a shard restart is a free restore.* That argument depends entirely on the leased value being +memory-only too, and here it is not. + +- A spawner is an `Item`. It is in the **world save**. +- A seasonal entry is written to `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. + +So a restart does not put either of them back. It puts the **change** back and throws away the +deadline timer that was going to undo it — leaving the world at the leased value with nothing on the +shard remembering that it is borrowed. That is the exact failure the lease framing exists to make +impossible, so the hold is persisted, in the Bridge's **third** save file (`Saves/Bridge/Leases.bin`, +beside `Participation.bin` and `Owned.bin`) and written by the same `EventSink.WorldSave` that writes +what it describes. + +**A deadline that passed while the shard was down fires at once**, rather than being dropped or +extended. The promise the website was given is "back at baseline by then"; a shard that was off for +the whole hold has not kept it, and restoring immediately is the only reading of that promise still +available. Extending it would silently turn a two-hour lease into however long the outage was. + +Config holds are still **not** written down, and the asymmetry is the point rather than an +inconsistency: the same argument, applied to planes where its premise is false. + +### 11.2 A target is a serial or a UniqueId, and both are needed + +- A **serial** is what `[props` shows a GM and what a rig can type. +- An **`XmlSpawner.UniqueId`** is what the shard's own `Spawns/*.xml` carry and what the live spawner + keeps. + +The second is not a convenience. A dropdown built from serials is **impossible**: serials are +assigned when the world is built and nothing off the shard knows them, so a lease addressable only by +serial could have no authoring list at all — it would be the free-text box the option-source contract +exists to replace. The atlas already read `` and discarded it; Phase 12b keeps it. + +The UniqueId lookup is a scan of `World.Items`, and stays one: it runs once per lease apply, which is +a rare human-scheduled operation, and a cache would be a second copy of the world to keep correct +across `[add` and deletion. + +### 11.3 The allowlist is checked against the object's own type + +A serial is a number a caller chooses. The only thing standing between `Spawner.MaxCount` and any +item on the shard is the check that the object found **is** one of the types the entry names — so +that check reads the object's own type rather than anything the caller sent, and it is +`IsInstanceOfType` rather than equality so a shard's own subclass of `Spawner` is leasable. + +Properties are read and written by reflection, bounded three ways: the (type, property) pair must be +in the shipped catalog, the property must carry `CommandProperty` (ServUO's own marker for *a staff +member may set this*, so this plane can never reach further into an object than `[set` could), and +its CLR type must be the one the entry knows how to render. Reflection rather than a hand-written +switch is what lets the boot self-check actually verify a pair; a switch would compile happily +against a property ServUO had renamed. + +**`Spawner` and `XmlSpawner` share all four property names**, which is a fact about this tree rather +than a convenience: the shard's own spawn files load as XmlSpawners while `[add spawner` makes the +native one, so a catalog naming only one of them would work until the day it did not. + +### 11.4 The boot self-check, and what it cannot do + +[`EVENTS.md`](../website/EVENTS.md) §N10: *the allowlist ships with the plugin and each key +self-checks at boot, dropping itself from the advertised catalog if the write does not take.* Better +a capability that disappears loudly than one that lies. + +**A config key is probed live** — written, read back, restored, all in one synchronous call — because +there is exactly one of it. + +**A property cannot be**, and that is a property of the thing rather than a shortcut: there are +thousands of instances and no canonical one, so probing would mean picking somebody's spawner at boot +and writing to it. What is verified instead is everything verifiable without touching the world: the +type still resolves, the property still exists on it, it is still public and settable, it still +carries `CommandProperty`, and its CLR type is still the one this plane can render. That is exactly +the failure N10 was written for — a property a later ServUO renamed or made read-only — caught at +boot rather than at 3am inside an unattended run. + +**And one failure no probe can catch is excluded by name.** `TreasuresOfTokuno` is left out of the +seasonal catalog because `SeasonalEventEntry.IsActive()` special-cases it and reads +`TreasuresOfTokuno.DropEra` rather than `Status`. Setting its status writes a field nothing consults: +the write succeeds, the value reads back, a compare-and-set restore passes, and every mechanism in +the plane reports a working lease over a capability that does nothing at all. It is N10's "capability +that lies" in its purest form, and the only way to find it is to read the source. + +### 11.5 What the seasonal toggle actually costs + +[`EVENTS.md`](../website/EVENTS.md) §G called this *"small and safe"* and got two things wrong. + +**It is a three-value enum over nine named events, not a nine-value enum.** `EventStatus` has three +values (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. That is a +different form to author and a different one to cap. (Corrected in 12a's survey; built here.) + +**And it is not small.** `SeasonalEventEntry.Status`'s setter fires `OnStatusChange()`, which calls a +`CheckEnabled()` that generates or removes world content for six of the eight permitted types — +Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. It is *safe*: ServUO does +exactly this to itself from a staff gump. But an author scheduling one is scheduling more than a flag +flip, and the label says so. + +### 11.6 `lease.list` answers the catalog and one row + +A targeted key has no single `current` — `Spawner.MaxCount` is worth something different on every +spawner — so a catalog walk cannot fill one in, while the website's `read()` needs exactly one value +for exactly one target before it applies anything. So the frame narrows: naming a `key` and a +`target` answers that row with its value. + +The frame also carries **`holds`**: every lease the shard is actually holding, whatever key or target +it is on. A catalog walk can enumerate the *keys* but never the *holds* on a targeted one — there is +no list of spawners to walk — so without it a reconcile after an outage would have no way to ask +"what are you still holding?". `inForce()` reads that. + +`current` is **omitted rather than defaulted** when it means nothing (a targeted row listed with no +target), and an unresolvable target answers `unreadable` with a reason. Sending `""` would make the +website record an empty baseline and later try to restore it. + +### 11.7 A vanished target is a success, not a failure + +Somebody deletes the spawner mid-run. There is nothing to restore and nothing owed, so the release +answers `targetGone: true` and the website records the row **reverted**. It is 12a's `gone` in the +lease plane's vocabulary, and for the same reason: reported as a failure it would sit in the ledger +unresolved for ever, over an object that no longer exists, retried by every sweep. + +Drift is unchanged and still not an error: the shard compared, declined to overwrite somebody's +deliberate change, and says so. + +--- + +## 12. The one-shots: neither owned nor borrowed + +Two verbs that cannot be taken back. Nothing is ledgered, because there is nothing core could come +back for. + +### 12.1 The item grant, and who receives it + +`ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both — +deliberately as **two** reversals, because permitting an event to create a creature says nothing +about permitting it to hand out loot. The four properties that make this a different proposition from +the one §8 refused: it is **declared** (an allowlist, never a free-text type reaching +`Activator.CreateInstance`), **bounded** (`EventsMaxGrantPerRun`, `EventsMaxGrantStack`, both +refusing rather than clamping), **attributable** (the run id rides on every grant), and **idempotent**. + +**The recipients are not sent, and that is the interesting decision.** A grant needs a list of +people, and the website has one in `event_run_participants` — but a module cannot read core's tables, +so the alternative was a new core surface handing participants to a module's `perform()`. It is not +needed: **the shard already has the list**, in protocol 6 part b's run-scoped participation ledger, +keyed by the same character serials the website's `member_key` holds. So the grant names a run and +the shard resolves who was there — no new core surface, no participant list crossing the wire twice, +and no window in which the two disagree. + +Two answers that look alike and are not: + +- **A run with no ledger open** is a `404`. The caller named something that does not exist here. +- **A run whose ledger is open and empty** is a `200` with `granted: 0`. An event nobody attended + still happened, and retrying against a ledger that will be just as empty next time would pause a + run for ever. + +**It is retryable, and protocol 6 is why.** [`EVENTS.md`](../website/EVENTS.md) §G called a grant +un-retryable because a lost acknowledgement and a grant that never applied were the same event — +exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` +closes it: a repeat is answered by the original reply, so a retried grant cannot be one winner +receiving two. §G was written before 11a and is corrected here. + +A non-stackable item in quantity is refused at **both** ends: five cloaks would be five items, five +chances to overflow a backpack halfway through with no way to say which half landed. An undeliverable +grant is **deleted rather than dropped** — `AddItem` failing on a full backpack would otherwise leave +the item in the world at (0,0), and an event that quietly littered the map with undeliverable rewards +would be worse than one that reported a miss. + +### 12.2 The world save + +`ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a phase +boundary — the point in an event after which what has happened is safe from a crash — and +`world.save.before` / `world.save.after` have been on the event stream since protocol 2, so the +acknowledgement it needs already exists. The reply says only that the save was **started**; a caller +that needs the completion watches the stream it is already connected to. + +**A save stops the world, so it is rate-limited rather than capped.** +`Bridge.EventsMinSaveIntervalSec` refuses a save that comes too soon after the last one — counting +ServUO's *own* autosave as the last one, because an event save thirty seconds after the hourly one is +the same freeze twice and the shard is the only half that can see both. **Refused, never queued:** a +queued save would land at a moment nobody chose, in the middle of whatever the next step is doing. + +It is the one refusal on this plane that waiting fixes, so it is a **429** rather than the 400 every +other refusal is — which keeps it out of the module's permanent-status set and makes a phase boundary +retried rather than abandoned. + +--- + +## 13. Routes and commands added by part b + +| Route | Command | Notes | +|---|---|---| +| `GET /lease?key=&target=` | `lease.list` | narrows to one row and fills `current`; always carries `holds` | +| `POST /lease` | `lease.apply` | `target` added | +| `POST /lease/release` | `lease.release` | `target` added; may answer `targetGone` | +| `GET /items` | `item.catalog` | the shard's grant allowlist and its bounds | +| `POST /items/grant` | `item.grant` | names a run, never a recipient list | +| `POST /world/save` | `world.save` | 429 when it comes too soon | + +New shard config: `Bridge.EventsMaxGrantPerRun` (200), `Bridge.EventsMaxGrantStack` (1000), +`Bridge.EventsMinSaveIntervalSec` (300). + +--- + +## 14. What part b found in already-merged code + +**The website's protocol pin never left 5.** `uo_link_config.protocol` reaches the sidecar as +`X-UOLink-Version` on every REST call, and the sidecar answers an exact mismatch with a `409`. Phase +11a took the wire to 6 and 12a took it to 7; **neither moved the pin**, in either of the two places +module-uo declares it (`db/schema.sql`'s `CREATE`/`MODIFY` defaults and `DEFAULT_PROTOCOL` in +`uoLinkConfig.model.js`). Every sidecar call on a real deployment would have been refused — the whole +event plane dead, loudly, for a reason nobody would look there for. + +It survived two phases because **both live walks set the column by hand while standing the rig up**, +which is exactly what makes a migration nobody runs invisible. 12b carries all three sites to 7. + +The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts +the three declarations agree **with each other**. That is a real check — they drifted apart once, in +the bug `DEFAULT_PROTOCOL` was introduced to fix — but *all three being equally stale passes it*, and +nothing in that repo can anchor it to the wire, which lives in `link` and `servuo-plugins`. The thing +that actually pairs them is the installer's bundle check at deploy time. Bumping the pin in the same +change as the emitters remains the discipline; no test replaces it. + +**A search term could not reach an option source.** `resolveOptionSource(id)` took no argument and +every source answered a flat list bounded at 2,000 entries. The spawner target is the first source +with more than that — 6,707 spawn points — so a flat list would have dropped two thirds of the world +and said nothing about which two thirds, which is precisely the failure 12a named for decoration. +`resolve({ q })` is additive: every source is passed a term and none is required to read one, and a +`searchable` flag says which do so the form renders a typeahead rather than a select that appears to +filter and does not. + +**`Spawner.Amount` does not exist.** [`EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) named it; on +ServUO 57.4 the property is `MaxCount`, and `MinDelay`/`MaxDelay` are `TimeSpan` rather than numbers +— so the wire carries seconds and the shard converts. Seconds rather than minutes because the spawn +files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot +express five seconds cannot express the shard's own data. diff --git a/website/EVENTS.md b/website/EVENTS.md index af52dd9..2e96a6d 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1127,23 +1127,28 @@ a capability exists. | Temporary decoration lockdown | ✅ **built (protocol 7)** | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. Phase 12a places it from the shard's OWN `Data/Decoration/**/*.cfg` vocabulary, carrying the item id: `Static` alone accounts for 5031 placements under **1992 different graphics**, so a bare type name places the wrong thing. Containers are refused — teardown would delete what a player left inside. | | Named, hued creatures from an allowlist | ✅ **built (protocol 7)** | 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. Phase 12a; the ledger is a PERSISTED shard-side registry, because a spawned creature survives the restart that proves a crier line gone. | | "Simple" boss variants | ✅ **built (protocol 7)** | 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. Phase 12a: an allowlisted type plus hits/damage/stat multipliers, each bounded. | -| **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`, already persisted across saves. **Corrected in Phase 12a's survey:** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. So the verb is a three-state toggle over nine named events — a different form to author and to cap than one nine-way choice. Phase 12b. | -| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. | +| **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 12b closed it at that one key and built the self-check anyway.** Counted on ServUO 57.4: of 156 non-Bridge `Config.Get` call sites, 82 sit outside a field declaration and all but four of *those* are inside a `Configure()` or static constructor, cached at boot exactly as the field initialisers are. The genuinely live, event-useful reads are this key and vendor bribe decay, which no event would lease. The self-check ships regardless — it exists for the operator whose OWN scripts read config live, and it is what keeps a capability that lies out of the catalog. | +| Lease a property on an existing object | ✅ **built (protocol 7)** | Practical, and an earlier revision was wrong to rule it out. Phase 12b: an allowlist of (type, property) pairs — `MaxCount`, `MinDelay`, `MaxDelay`, `Running`, on both `Spawner` and `XmlSpawner`, which share all four names — addressed by a serial or by an `XmlSpawner.UniqueId`. **`Spawner.Amount` does not exist**; the plan named it and the property is `MaxCount`. The hold is PERSISTED, unlike a config lease's: a spawner is in the world save, so a restart preserves the change and destroys only the timer that would undo it. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state, and this is the first lease a GM can actually drift. | +| Grant an event item | ✅ **built (protocol 7)** | An ordinary action, not a special contract member. Admin-gated, capped by `uo.rewards`, ledgered, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. Phase 12b resolves the RECIPIENTS on the shard, from protocol 6's participation ledger, keyed by the same serials core stores as `member_key`: the website has the list too, but reaching it would have meant a core surface handing a module core's own participants. **This row said failure aborts rather than retries; protocol 6 changed that** — an idempotency key means a retried grant is answered by the original reply, so it is retryable like every other verb. | +| Toggle a ServUO seasonal event | ✅ **built (protocol 7)** | **Corrected twice.** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries — so the verb is a three-state toggle over named events. And it is not "small and safe": the setter fires `OnStatusChange()`, which generates or removes world content for six of them. Safe (ServUO does it to itself from a staff gump), but not small. **Eight of the nine**: `TreasuresOfTokuno` is excluded because `IsActive()` reads its own `DropEra` rather than `Status`, so a lease on it would apply cleanly, read back, restore cleanly and do nothing — §N10's "capability that lies", and the one instance no runtime probe can catch. | +| Trigger a world save | ✅ **built (protocol 7)** | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built until Phase 12b. `AutoSave.Save()`; the `world.save.*` events it emits are already streamed, so the reply says only that the save STARTED. A save stops the world, so it is RATE-LIMITED rather than capped — `Bridge.EventsMinSaveIntervalSec`, counting ServUO's own autosave as the last one, refusing rather than queueing, and answering 429 because it is the one refusal on this plane that waiting fixes. | | Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. | | Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. | | Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. | -**What the reopening actually costs.** Nine new plugin verbs and **two** protocol bumps: 6 -for the idempotency key, the lease deadline and the participation ledger, and 7 for the world -verbs themselves. (One bump was the estimate; the guarantee had to land before the verbs that -depend on it, which is [`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is -cap-bounded, ledgered and attributable by construction. **None of it is `[add`.** The distinction that -makes it defensible: an event may **own** what it creates and **borrow** what it changes — and a -borrowed value carries a deadline the game itself enforces. What it may never do is change something -with no baseline recorded, which is exactly what `[set` is. +**What the reopening actually cost.** Two protocol bumps — 6 for the idempotency key, the lease +deadline and the participation ledger, 7 for the world verbs and the two borrowed planes. (One bump +was the estimate; the guarantee had to land before the verbs that depend on it, which is +[`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is cap-bounded, +ledgered and attributable by construction. **None of it is `[add`.** The distinction that makes it +defensible: an event may **own** what it creates and **borrow** what it changes — and a borrowed +value carries a deadline the game itself enforces. What it may never do is change something with no +baseline recorded, which is exactly what `[set` is. + +**One thing the survey changed about "borrow".** A borrowed value whose home is the world save — an +object property, a seasonal status — does not come back on a restart the way a config value does, so +its HOLD is persisted on the shard and its deadline re-armed at load. The fail-safe still holds; it +just has to be written down for the planes where a restart is not itself the restore. --- diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 5f8a4ad..9bbd2fb 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1440,6 +1440,8 @@ anywhere in it. #### Phase 12a — what an event OWNS +**Built and merged to `edge`** — `servuo-plugins#23`, `link#38`, `Module-uo#31`, `docs#221`. + Five verbs that put something in the world, ledger its serial, and delete it at teardown. | Verb | Cap dimension | Author's action | @@ -1540,6 +1542,76 @@ caller in the whole tree. three-state toggle over nine named events, which is a different form to author and a different one to cap. +**Built** — `website#—`, `servuo-plugins#—`, `link#—`, `Module-uo#—`, `docs#—`, all onto `edge`. +Spec of record: [`../link/v7.md`](../link/v7.md) §11–§14. + +**It is FIVE repos, and `website` is the one 12a did not need** (org lead, 2026-09-07: *"you can do +the 5 repos and no API bump since it is still on edge"*). A targeted lease is a shape `core.lease` +did not have. Every lease before it named a single value, so the lease id WAS the target and none of +the four callables took one; `Spawner.MaxCount` is one capability over thousands of spawners, and a +reservation on the id alone would let one run turning up one spawner refuse every other run every +other spawner. So the declaration gains a `target`, the callables gain it, and the ledger ref becomes +`#` — which puts the two-events-one-target refusal at the granularity the world +actually has, and leaves it coming from the same unique index it always did. + +**Extending core rather than giving the module a lease verb of its own is what §F already decided** +(Phase 8, *"the verb is core's"*): a lease verb per module would re-implement `maxDurationMs` and the +conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half of +that objection no longer holds — the target check comes free from the index whichever verb reserves +the row — and the other half still does. **`MODULE_API_VERSION` stays 1.10.0, amended in place**, the +shape every phase since P10 has used while this workstream sits on `edge`. + +Two more contract members came with it, both forced by this phase rather than chosen: **`values` on a +`string` lease** (the seasonal status is a three-value enum and nothing bounded `string`, so its only +check was the game side's — a refusal arriving unattended, mid-run) and **searchable option sources** +(see below). + +**The spawner dropdown was the phase's one genuine blocker, and the answer was a core change** (org +lead, 2026-09-07). `resolveOptionSource(id)` took no argument and every source answered a flat list +bounded at 2,000. This tree has **6,707 spawn points**, so a flat list would have dropped two thirds +of the world and said nothing about which two thirds — the exact failure 12a named for decoration, +arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to +read one, and a `searchable` flag says which do. The atlas also keeps `` again (parser +version 4), because it is the only name for one particular spawner that exists off the shard — a +serial is assigned when the world is built, so a lease addressable only by serial could have had no +dropdown at all. + +**Three plan assumptions the tree disproved.** + +- **`Spawner.Amount` does not exist.** The property is `MaxCount`, and `MinDelay`/`MaxDelay` are + `TimeSpan` rather than numbers, so the wire carries seconds. `Spawner` and `XmlSpawner` share all + four names, which is why one catalog covers both — and why a catalog naming only one of them would + have worked until the day it did not. +- **A property lease's hold must be PERSISTED**, and the config lease's must not. 11b's fail-safe is + *"a lease that never reaches disk means a restart is a free restore"*, which depends on the leased + value being memory-only too. A spawner is in the world save and a seasonal status is in + `Saves/Misc/SeasonalEvents.bin`, so a restart preserves the CHANGE and destroys only the timer that + would undo it. The Bridge gains its **third** save file, written by the same `EventSink.WorldSave` + as what it describes; a deadline that passed while the shard was down fires at once. +- **The seasonal toggle is not "small and safe", and one of the nine lies.** `OnStatusChange()` + generates or removes world content for six of them — safe, since ServUO does it to itself from a + staff gump, but not small. And `TreasuresOfTokuno` is excluded: `IsActive()` reads its own + `DropEra` rather than `Status`, so a lease on it applies cleanly, reads back, restores cleanly and + does nothing at all. That is §N10's "capability that lies" in its purest form and the one instance + no runtime probe can catch, so it is excluded by name at both ends. + +**Who receives a grant is answered on the SHARD.** The website has the list in +`event_run_participants`, but a module cannot read core's tables, so the alternative was a new core +surface handing participants to a module's `perform()`. It is not needed: protocol 6 part b's +participation ledger already holds them, keyed by the same character serials core stores as +`member_key`. A run with no ledger open is a 404; a run whose ledger is open and empty is a 200 with +`granted: 0`, because an event nobody attended still happened. And **the grant is retryable** — §G +called it un-retryable before protocol 6 existed, and an idempotency key means a repeat is answered +by the original reply. + +**One defect in already-merged code, and it would have broken everything.** The website's protocol +pin never left **5**: `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` and an exact +mismatch is a `409`, so on any real deployment every sidecar call would have been refused from Phase +11a onward. It survived two phases because both live walks set the column by hand while standing the +rig up. 12b carries all three declaration sites to 7. The test that guards them asserts they agree +WITH EACH OTHER, which is a real check they once failed — but all three being equally stale passes +it, and nothing in `module-uo` can anchor it to the wire. + **Ships:** the invasion. **Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted` diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 4fd9c63..290eaa7 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -103,6 +103,12 @@ ctx.events.reconcile() api.registerEventOptionSources([{ id: 'uo.options.creatures', label: 'Creatures', async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] }, +}, { + // A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and + // requires it of none, so a resolver that ignores it is unchanged; `searchable` + // is what tells the authoring form to render a typeahead rather than a select. + id: 'uo.options.spawners', label: 'Spawners', searchable: true, + async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) }, }]) // A value a run may borrow. The module ships the three callables; the VERB an @@ -115,6 +121,28 @@ api.registerEventLeases([{ async apply(v, until) { return { ok: true } }, async restore(baseline, { expected }) { return { ok: true } }, async inForce() { return { ok: true, held: true } }, // optional +}, { + // A TARGETED lease: one capability over many things. Core adds the target to + // the reservation ref (`#`) so two runs may hold the same key + // on two different objects, and hands it to all four callables. + id: 'uo.spawner.maxcount', label: 'Spawner: how many at once', + type: 'int', min: 0, max: 100, maxDurationMs: 43200000, + target: { label: 'Which spawner', source: 'uo.options.spawners' }, + async read({ target }) { return { ok: true, value: '3' } }, + async apply(v, until, { target }) { return { ok: true } }, + async restore(baseline, { expected, target }) { return { ok: true } }, + async inForce({ target }) { return { ok: true, held: true } }, +}, { + // A string lease may close its value set. `min`/`max` bound the numeric types + // and nothing bounded `string`, so without this the only check on the value is + // the game side's -- a refusal arriving unattended, mid-run, rather than on the + // authoring form. + id: 'uo.seasonal.status', label: 'Seasonal event status', + type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000, + target: { label: 'Which seasonal event', source: 'uo.options.seasonal' }, + async read({ target }) { return { ok: true, value: 'Inactive' } }, + async apply(v, until, { target }) { return { ok: true } }, + async restore(baseline, { expected, target }) { return { ok: true } }, }]) ``` @@ -133,6 +161,23 @@ field: - **`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. +- **A lease that declares a `target` is a family of values, and core changes what it reserves.** + Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a + config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands + of spawners and reserving the id would let one run turning up one spawner refuse every other run + every other spawner. With one, the ref is `#`, the two-events-one-target index + bites at the granularity the world actually has, and the target reaches all four callables. + **Core refuses a targeted lease with no target and an untargeted one with a target**, both + `retry: false`: the second attempt has the same params. `target.source` names an option source + for the authoring form, and is not resolved by core at registration — a source registered by a + module that boots later must not make this one throw. +- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the + numeric types; a set on an int lease would be a second bound beside them with no rule about + which wins, so it is refused. +- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this + existed behaves identically. Declare `searchable: true` when the term actually narrows the + answer — the form reads that to decide between a typeahead and a select, and inferring it from a + truncated list would read correctly right up until a small deployment's list happened to fit. 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 @@ -315,6 +360,38 @@ Two members joined it in Phase 10, both on an envelope: envelope rather than in `events/` because "this particular firing is narrower than the kind usually is" is a fact any emitter can have. +Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended +into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there +is no deployment that could tell the difference, and the events cutover is what publishes the +whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose +and stays so until the cutover re-pins `ci/core-ref.json`. + +- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a + FAMILY of values rather than a single value, and core reserves `#` rather than + the id — so two runs may hold the same key on two different objects while two runs holding one + object still collide on the unique index. The target reaches `read`, `apply`, `restore` and + `inForce`. Every lease before this named one value, so the id *was* the target and none of the + four needed an argument; a property does not have that shape. + + **The verb stays core's**, which is the whole reason this is an extension rather than a lease + verb of the module's own. §F settled that in Phase 8: a lease verb per module would + re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and + wrong in the first one that forgot. Half of that objection no longer holds — the + two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the + row — and the other half still does. +- **`values` on a `string` lease.** The closed set an author may choose from, checked by + `core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded + `string`, so the only check on a string lease's value was the game side's — a refusal arriving + unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside + `min`/`max` would be a second bound with no rule about which wins. +- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose + catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the + argument answers exactly as it did before this existed, which is what makes it additive. The + first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the + 2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says + nothing about which part. `searchable` is declared rather than inferred, because inferring it + from a truncated answer reads correctly right up until a small deployment's list happens to fit. + **1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerEventTriggers([...])`, `api.registerAudiences([...])` and @@ -664,8 +741,8 @@ 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, inForce }]) // 1.10.0 -api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0 +api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 +api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ```