diff --git a/README.md b/README.md index 0edb670..d057676 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ particular game; a module is what makes it a site *for* one. | [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework | | [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) | | [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**, shipped 2026-09-01 as bundle 2026.09.01 (sidecar v2.1.0 + overlay v1.1.0) | +| [v6.md](link/v6.md) | Protocol 6 — idempotent commands, config leases with a shard-side deadline, and the run-scoped participation ledger. On `edge`; reaches `main` at the events cutover | +| [v7.md](link/v7.md) | Protocol 7 — the world verbs an event OWNS: creatures, bosses, oracle NPCs, temporary gates, decoration, and the persisted ownership registry behind them. On `edge` | | [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) | | [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge | | [PLAN.md](link/PLAN.md) | uo-link build plan | diff --git a/link/v7.md b/link/v7.md new file mode 100644 index 0000000..bf8e800 --- /dev/null +++ b/link/v7.md @@ -0,0 +1,295 @@ +# 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) +**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. + +--- + +## 1. The payload protocol 6 was the floor for + +[`v6.md`](v6.md) §1 said it plainly: *"Protocol 12's world verbs are the payload; protocol 6 is the +floor they stand on."* This is that payload. + +Five verbs an event author sees — creatures, an enhanced "boss", an oracle NPC, a temporary gate, +decoration — and every one of them is the same sentence underneath: + +> **An object exists in the world, and this run owns it.** + +Ownership is the whole of why [`EVENTS.md`](../website/EVENTS.md) §G can exclude arbitrary `[add` +and `[set` and then permit all of this in the same table. The rule it draws is: + +> An event may **own** what it creates and **borrow** what it changes, and may never change +> something with no baseline recorded. + +12a is the first half. 12b is the second. + +--- + +## 2. One command family, not five + +| Command | Answers | | +|---|---|---| +| `world.spawn` | `world.ok` | Places `count` of `what` at a map/x/y; replies with the serials | +| `world.despawn` | `world.ok` | `removed` / `gone` / `refused`, per serial | +| `world.owned` | `world.owned.ok` | What the run still owns, pruning dead rows as it walks | + +Plus one unsolicited frame, `world.expired`, when the shard collects something on its own. + +**`what` is a field, not five kinds** (org lead, 2026-09-07). The per-verb differences — a boss's +multipliers, an oracle's lines, a gate's destination and deadline — ride alongside the discriminator +rather than justifying their own command. That buys one ledger shape, one teardown path and one +reconcile instead of five near-identical ones across three repos, and it costs nothing an author can +see: `module-uo` still declares **five actions**, because five is what they are. The discriminator +is a wire detail, and a dropdown of `what` would be a worse authoring form than five named steps. + +```json +{"kind":"world.spawn","reqId":"r-4","runId":"77","idempotencyKey":"…", + "what":"creature","map":"Felucca","x":1496,"y":1628, + "count":8,"type":"Orc","name":"Rotting Orc","hue":1157,"spread":6} +``` + +```json +{"kind":"world.ok","reqId":"r-4","action":"spawn","runId":"77","what":"creature", + "placed":8,"serials":["0x40001A2B","…"],"owned":8} +``` + +Serials cross as `"0x…"` text, matching every other serial this bridge writes. + +--- + +## 3. The ownership registry, and why it is persisted + +**This is the design decision of the phase, and it was forced rather than chosen.** + +Start from what is already known. A spawned creature is a **world object**, so unlike a town-crier +line it *survives a restart*. That alone settles one question: `reconcileByBootId` — the trick +Phase 9 uses for the crier and the news gump, where a changed `bootId` **is** proof the thing is +gone — is wrong here. It would report gone the one class of resource still standing in Britain. +Reconcile has to ask. + +The harder question is where the record of *which run owns which serial* lives, and the two obvious +answers are both wrong: + +- **In memory on the shard.** Lost in the restart the creatures themselves survive. The website then + holds serials the shard will not vouch for, and thirty orcs stand in Britain until somebody + deletes them by hand. +- **Only in the website's ledger.** Then it is not held on the shard at all, and `world.despawn` + would have to delete whatever serial it was handed. *"An event never touches a creature it did not + create"* — the sentence the boss verb is built on — would be an intention with no mechanism behind + it, and a bug in a step or a stolen sidecar token would be a delete-anything primitive. + +So the Bridge gains its **second** persisted file, `Saves/Bridge/Owned.bin`, beside 11b's +`Participation.bin`. Same mechanism (`Server.Persistence` plus `EventSink.WorldSave`), same rule +about attaching the hooks in `Configure()` rather than `Initialize()` — `EventSink.WorldLoad` fires +*inside* `World.Load()` — and the same unconditional attachment ahead of the enabled gate, so an +operator who switches the plane off for an afternoon does not come back to a truncated registry and +a world full of creatures nothing admits to owning. + +### 3.1 The registry and the objects cannot get out of step + +Worth stating because a reader will reach for it as an objection: if the registry were lost while +the objects survived, `world.owned` would answer with an empty hand and the website would orphan +live creatures. + +It cannot happen. The registry is written by `EventSink.WorldSave` — **the same save** that writes +the objects it describes. They are saved together and lost together. That is what makes "anything +the shard does not list is gone" a safe reading rather than a hopeful one, and it is why +`world.owned` on a run the shard has never heard of is an **empty list and a 200**, not a 404: +*"this run owns nothing"* and *"I have never heard of this run"* are the same fact once the registry +is the only record of ownership. + +### 3.2 Three answers to a despawn, and only one is a failure + +| | Meaning | +|---|---| +| `removed` | Owned by this run, found, deleted. | +| `gone` | Owned by this run and already absent. **A success.** | +| `refused` | Not this run's to delete. The row stays; nothing is touched. | + +**`gone` being a success is not a technicality.** Players kill event creatures — that is what +spawning them is *for*. §L already settles it (*"gone, and that is fine"* is a successful revert), +and a run that ended `incomplete` because its event worked would be a report nobody could read. + +`refused` is the only answer here that means somebody asked for something they should not have, and +it is reported back to core as a per-row `failed` so the row lands unresolved **with a reason** +rather than being quietly marked reverted. Nothing will ever delete it through this path. + +--- + +## 4. The oracle is ours, and ServUO's own dialogue engine is why + +ServUO ships a complete dialogue system in `XmlSpawner2.XmlDialog`, and its `SpeechEntry` is the +evidence that the shape this verb wants is right rather than invented: + +| `XmlDialog.SpeechEntry` | This verb | +|---|---| +| `Text` | the line | +| `Keywords`, comma-separated | the keywords | +| an entry with **no** keywords fires automatically | the greeting | +| `defProximityRange = 3` | `Bridge.EventsOracleGreetRange` | +| `LockConversation` | a per-player cooldown | +| **`Action` — XmlSpawner's command-scripting language** | **nothing** | + +That last row is the whole reason not to build **on** it. Routing authored dialogue through +XmlDialog would leave an arbitrary-command field one field away from an event author on a web form: +the `[set` §G excludes, arriving through the back door, in a subsystem this overlay does not own and +an operator can switch off. + +What the verb actually needs are two native virtuals on `Server.Mobile`: + +- **`OnMovement(m, oldLocation)`**, delivered to **every** mobile in range. The `HandlesOnMovement` + filter applies only to *Items* — `Server/Mobile.cs:3369` against `:3375` — so a greeting hook is + free. The old location is compared as well as the new one, which makes it fire once per approach + rather than on every step of a passer-by. +- **`HandlesOnSpeech(from)` → `OnSpeech(e)`** (`Server/Mobile.cs:5150`), the keyword hook. + +Nothing executable crosses the wire: keywords and text. + +**The oracle cannot be killed, moved or looted.** `CanBeDamaged()` is false as `TownCrier`'s is, and +it is `Blessed`, `Frozen` and `CantWalk`. An event NPC a player can drag out of the venue is one +that stops being where the run's ledger says it is, and teardown deleting something that has +wandered two screens away is worse than it not moving. + +**Keywords are lower-cased on the website side, not at match time.** The comparison on the shard is +then ordinal, between two strings already in the same case — culture-aware casing at match time +would make an oracle answer differently on a Turkish shard. + +--- + +## 5. Decoration comes from the shard's own decoration files + +`Data/Decoration/**/*.cfg` names every item type the shard already uses as scenery, with its item +id. The atlas build indexes them, so the authoring dropdown is derived from the operator's own tree, +resolves with the shard down, and is *decoration* by the shard's own definition rather than by ours. + +Two measurements shaped the wire: + +- **120 files, nested two deep** (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`), so + the read is recursive. A flat read would index a fraction of it while looking like it worked — + the failure being a dropdown quietly missing whole expansions rather than an error anyone notices. +- **313 distinct types, and the item id is not decoration.** `Static` alone accounts for **5031 of + the placements under 1992 different graphics**, because for that class the graphic *is* the + identity: a bare `new Static()` is never the switch or the paving stone the author picked. 131 of + the 313 types carry more than one id (a door has one per facing). + +So `world.spawn` carries an optional **`itemId`** for decoration, resolved on the website from the +atlas row rather than typed by an author — which also means the verb places only what this shard's +decoration files actually name, a tighter boundary than "any item that is not a container". + +**The id is never applied to a `BaseAddon`.** An addon is a group of components and its own `ItemID` +is not what a player sees; writing a graphic over it would leave a stone oven rendering as one +arbitrary tile of itself. Addons construct with the right appearance already. + +**Containers are refused outright.** Deleting one at teardown would delete whatever a player had +left inside it. Everything else here is safe to delete because the event made it; a container's +contents are not. + +--- + +## 6. Ceilings refuse, they do not clamp + +`Bridge.EventsMaxCreatures` (30), `EventsMaxBosses` (4), `EventsMaxNpcs` (5), `EventsMaxDecor` (60), +`EventsMaxGateMinutes` (240), `EventsMaxOwnedPerRun` (200), `EventsMaxSpread` (40), +`EventsMaxBossMultiplier` (10.0), and the oracle's own five. Defaults are the EM Program's published +quotas, because they are the only numbers anyone has defended in public. + +These are the shard's **independent** bounds, not mirrors of the module's budget dimensions — +`Bridge.LeaseMaxDurationSec`'s argument from [`v6.md`](v6.md) §8.2 unchanged: the bound exists for +the case where the website is wrong, and being loud about it is its whole value. A refusal is a +**400**, and permanently so: retrying *"you asked for 80 creatures and this shard places 30"* gets +the same answer forever, so a module that classified it as transient would put a run in a loop +against a limit that will never move. + +`EventsMaxOwnedPerRun` is the one that is not a per-call ceiling. The others bound one request; that +one bounds a run calling a verb in a loop, which is the shape a runaway schedule actually takes. + +**The gate deadline lives on the shard**, for [`v6.md`](v6.md) §8.3's reason about leases: a gate the +shard closes by itself closes whether or not the website is ever heard from again. A run whose +engine died leaves a world that comes back early rather than one stuck open. And it crosses as a +**duration** (`holdMs`), never an absolute time — an absolute deadline is measured against two +clocks, and a shard ten minutes fast would collect the gate the instant it opened. + +**`Bridge.EventsEnabled` gates all of it** (org lead, 2026-09-07). Spawning creatures and opening +gates is the same consent 11b introduced that switch for — unattended, scheduled world change — not +a third one. A switch per verb family is a setting nobody can reason about. + +--- + +## 7. Visibility + +`world.expired` is **deliberately unmapped**, exactly as `lease.applied` and `lease.expired` are and +for the reason [`v6.md`](v6.md) §4 gives: it is an operational record of the *website* changing this +shard's world, and rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail +of the site's own writes belongs. Mapping it would mean choosing a feature an operator could then +widen, and there is no rung below admin it belongs on. + +**A spawn emits no stream frame at all**, and the asymmetry is deliberate. A reply correlated on +`reqId` already tells the website everything it needs, and eight creatures placed in one call would +otherwise be eight frames announcing what the caller already knows. `world.expired` exists precisely +because it is the *only* thing here that happens without the website asking. + +--- + +## 8. One version, two halves + +Phase 12 is split. **12a bumps `PROTOCOL_VERSION` to 7; 12b amends 7 in place** on `edge` — the +11a/11b shape, and it applies for the same reason: 7 will not reach `main` until the events cutover, +so the bundle CI never sees two numbers. + +It carries the same hazard [`v6.md`](v6.md) §7 states, and it is worth restating rather than +assuming anyone will follow the link: **an overlay and a sidecar both declaring 7 are interchangeable +only within one side of the 12b merge.** That is tolerable for one reason and no other — nothing is +released from `edge`. It would not be tolerable on `main`. + +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. + +--- + +## 9. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | `BridgeWorld.cs` (registry, three handlers, the sweep), `BridgeOracle.cs`, the `EventsMax*` keys, `overlay.toml` → 7 | +| `link` | `POST /world`, `GET /world/:runId`, `POST /world/:runId/despawn`; `PROTOCOL_VERSION` → 7 | +| `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. + +--- + +## 10. Verification + +The whole rig: a run that spawns one of each of the five, a restart mid-run proving the ownership +registry survives it and reconcile still answers, a creature killed by a player proving `gone` is an +ordinary teardown outcome, and a despawn **refused** for a serial the run does not own. + +### 10.1 What the build already found + +**An action must be performable from its own required params, and `uo.npc.place` was not.** Both +ends refuse an oracle with neither a greeting nor a line — it would stand there in silence — but +both fields were declared optional, so the verb could not be performed from its own required set and +no authoring form could render it as valid either. A cross-field *"at least one of these"* rule is +the wrong shape for a declaration core reads as data. The greeting is now `required`, which says the +same thing in the contract itself; the `perform()` check remains, for the field holding nothing but +spaces. + +It was caught by `module-uo`'s existing dry-run sweep — a test that performs every action with only +its required params — which is a better argument for that test than anything written about it when +it shipped. + +**The creature option source answered with something unusable.** `uo.options.creatures` shipped in +Phase 9, before anything consumed it, carrying the atlas **slug**: unique, stable, and not a thing +the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is +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. diff --git a/website/EVENTS.md b/website/EVENTS.md index eb9c0b0..af52dd9 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1122,22 +1122,24 @@ a capability exists. | 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** | ✅ 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. | +| Oracle NPC with scripted dialogue | ✅ **built (protocol 7)** | 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. Phase 12a: a greeting on approach plus keyword rows, on `Mobile.OnMovement` and `Mobile.OnSpeech`. Built rather than layered on `XmlSpawner2.XmlDialog`, which implements exactly this vocabulary **and an `Action` command-scripting field** — the `[set` this table excludes, one field away from an author. | +| Temporary gate to a venue | ✅ **built (protocol 7)** | 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. Phase 12a; the deadline is the SHARD's, so a gate closes whether or not the website is heard from again — and it crosses as a duration, never an absolute time. | +| 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` over a nine-value enum, already persisted across saves. | +| 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. | | 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, one protocol bump carrying an -idempotency key and a lease deadline, and a participation ledger. Bounded work, and every verb is +**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 diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index a4518c6..5f8a4ad 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1423,26 +1423,122 @@ reachable. ### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) **§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below -stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and -either owned or borrowed: +stands. + +**Split into 12a and 12b (org lead, 2026-09-07)**, on the line §G already draws: what an event +**owns** and what it **borrows**. Ten verbs, four repos and a protocol bump is one review and one +walk too many, and the two halves prove different things — 12a proves a run can put things in the +world and get all of them back, 12b proves it can change something it did not create and give that +back unchanged. They land as **one protocol version**: 12a bumps to **7**, 12b amends 7 **in place** +on `edge`. That is the 11a/11b shape, and it carries the same hazard §7 of `v6.md` states — an +overlay and a sidecar both declaring 7 are interchangeable only within one side of the 12b merge — +tolerable for the same single reason and no other: nothing is released from `edge`, so the bundle CI +never sees two meanings of 7. + +`installer` is not in this phase, for the reason Phase 11 records: no protocol version is hardcoded +anywhere in it. + +#### Phase 12a — what an event OWNS + +Five verbs that put something in the world, ledger its serial, and delete it at teardown. + +| Verb | Cap dimension | Author's action | +| --- | --- | --- | +| Named, hued creatures from the atlas's ~800 constructible types | `uo.creatures` | `uo.creature.spawn` | +| "Simple" boss variants, as event-owned creature templates | `uo.bosses` | `uo.boss.spawn` | +| Oracle NPCs with scripted dialogue | `uo.npcs` | `uo.npc.place` | +| Temporary gates | `uo.gate.minutes` | `uo.gate.open` | +| Temporary decoration | `uo.decor` | `uo.decor.place` | + +**One command family, five author verbs** (org lead, 2026-09-07). Every row above ends in "an object +exists and this run owns it", so the wire carries `world.spawn` / `world.despawn` / `world.owned` +with a `what` discriminator, and the per-verb differences — a boss's stat multipliers, an oracle's +lines, a gate's target and deadline — are **fields rather than kinds**. One ledger shape, one +teardown path, one reconcile, instead of five near-identical ones in three repos. An *author* still +sees five verbs, because five is what they are: the discriminator is a wire detail, and a dropdown of +`what` would be a worse form than five clearly-named steps. + +**The caps are the module's, never core's** (org lead, 2026-09-07). `uo.creatures` and the rest are +declared by `module-uo` through `registerEventBudgets`, exactly as `uo.broadcasts` already is; core +meters whatever dimensions a module declares and holds no UO knowledge — which is the whole of what +§F means by game-agnostic. The shard additionally carries its own `Bridge.EventsMax*` ceilings and +**refuses rather than clamps**, on `Bridge.LeaseMaxDurationSec`'s argument from 11b unchanged: the +shard's bound exists for the case where the website is wrong, and being loud about it is its value. + +**Ownership is persisted, and that is forced rather than chosen.** A spawned creature lives in the +world save, so unlike a crier line it *survives* a restart — which already means +`reconcileByBootId` is wrong here, for the reason it was wrong for the participation ledger, and +reconcile has to ask. But the record of *which run owns which serial* has nowhere else to live. Held +in memory it is lost in the restart the creatures survive, orphaning them. Held only in the website's +ledger it is not held on the shard at all, so `world.despawn` would delete whatever serial it was +handed — and "an event never touches a creature it did not create" is the sentence the boss verb is +built on. So the Bridge gains its **second** persisted file, beside `Participation.bin`. + +**The oracle is our own, and ServUO's own dialogue engine is the reason for both halves of that.** +`XmlSpawner2.XmlDialog` already implements exactly the vocabulary this verb wants — `Text` plus a +comma-separated `Keywords` list, an entry with no keywords being the greeting, a proximity range, a +per-player conversation lock — which is evidence the shape is right rather than invented. It is +also the reason not to build **on** it: `SpeechEntry` carries an `Action` string, XmlSpawner's +command-scripting language, and routing authored dialogue through XmlDialog would leave an +arbitrary-command field one step from an event author. That is the `[set` §G excludes, arriving by +the back door, in a subsystem we do not own and a shard can switch off. `Mobile.OnMovement` +(delivered to **every** mobile in range — the `HandlesOnMovement` filter applies only to Items, +`Server/Mobile.cs:3369` against `:3375`) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals +and are the whole of what the verb needs. + +**Decoration comes from the shard's own decoration files.** `Data/Decoration/**/*.cfg` names every +item type the shard already uses as decoration, with its item id (`LargeCrate 0x0E3C`). The atlas +build indexes them, so the dropdown is derived from the operator's own tree and resolves with the +shard down — and the list is "decoration" by the shard's own definition rather than by our taste. +The plugin validates the type independently, because it cannot trust the website. + +**Ships:** an event can populate a venue — creatures, a boss, an oracle, a gate to reach it and +decoration around it — every piece cap-bounded, ledgered by serial, and gone at teardown. +**Verify:** the whole rig. A run that spawns one of each of the five; a restart mid-run proving the +ownership registry survives it and reconcile still answers; a creature killed by a player proving +"gone" is an ordinary teardown outcome and not a failure; and a despawn **refused** for a serial the +run does not own. + +#### Phase 12b — what it BORROWS, and the one-shots | Verb | Owned or borrowed | Cap dimension | | --- | --- | --- | -| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` | -| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` | -| Oracle NPCs with scripted dialogue | owned | `uo.npcs` | -| Temporary gates | owned, with a deadline | `uo.gate.minutes` | -| Temporary decoration lockdown | owned | `uo.decor` | -| A live config value | **borrowed** — a lease | — | | A property on an existing object | **borrowed** — a lease | — | +| A live config value | **borrowed** — a lease | — | | Seasonal-event toggle | borrowed | — | | World save | neither — a one-shot | — | | Item grant | owned, `reversible: 'none'` | `uo.rewards` | -**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split -between live reads and values cached at type initialisation, and a lease on the second kind applies -cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the -advertised catalog if it does not take — a capability that disappears loudly beats one that lies. +**The config lease catalog does not grow, and the promise of "the rest of the allowlist" was written +before anyone counted.** Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites in +`Scripts/`; 82 sit outside a field declaration, but all but four of *those* are inside a +`Configure()` or a static constructor, and so are cached at boot exactly as the field initialisers +are. The genuinely live, event-useful reads are `PlayerCaps.SkillCap` — which 11b already shipped +— and `Vendors.BribeDecayMinTime`/`MaxTime`, which is vendor bribe decay and which no event would +plausibly lease. (`Staff.*` in `GMbody.cs` is live, and is staff-body cosmetics.) So **12b's lease +work is object-property leases**, and the config half of the catalog is finished at one key. + +**The boot-time self-check ships anyway** (org lead, 2026-09-07). On a stock shard it guards a +one-key catalog, which is not why it exists: it exists for the operator whose *own* scripts read +config live, and it is the mechanism that keeps a capability that lies out of the advertised catalog. +A key that sets, reads back and restores at boot stays; one that does not drops itself and says so. +§D and §G of `EVENTS.md` carry the measurement, so nobody re-plans against "258 call sites, two +patterns". + +**An object-property lease names its target by an allowlist of (type, property) pairs, addressed by +serial** (org lead, 2026-09-07). The plugin ships the catalog — `Spawner.Amount` / `MinDelay` / +`MaxDelay` to start — and refuses any serial whose type is not in it. That mirrors the config +catalog exactly, and keeps §G's "a curated allowlist the plugin ships" true of both halves of the +lease plane; the alternative, a property name and a serial taken on trust, is `[set` with extra +steps. It also finally puts compare-and-set in front of a real hand: a spawner is trivially drifted +with `[props`, which is the test 11b could run only with scaffolding, because `Config.Set` has one +caller in the whole tree. + +**§G describes the seasonal toggle wrongly, and 12b corrects it.** It calls +`SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum". `EventStatus` has **three** values +— `Inactive`, `Active`, `Seasonal` — and it is `EventType` that has nine entries. The verb is a +three-state toggle over nine named events, which is a different form to author and a different one +to cap. **Ships:** the invasion. **Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown