`link/v7.md` is the spec of record: one command family for five verbs, the persisted ownership registry and why it is forced rather than chosen, the oracle that reuses ServUO's own dialogue vocabulary without its command-scripting field, and the decoration index. `EVENTS_PLAN.md` splits Phase 12 into 12a and 12b (org lead, 2026-09-07), on the line section G already draws between 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 halves prove different things. One protocol version across both, the 11a/11b shape. Three corrections the survey forced on `EVENTS.md`: The config lease catalog does NOT grow in 12b, and the plan's promise of "the rest of the allowlist" was written before anyone counted. Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites; 82 sit outside a field declaration, but all but four of those are inside a `Configure()` or a static constructor and are cached at boot exactly as the field initialisers are. The live, event-useful reads are `PlayerCaps.SkillCap` (11b shipped it) and vendor bribe decay. So 12b's lease work is object-property leases. Section G calls the seasonal toggle "a nine-value enum". `EventStatus` has THREE values; 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 to cap. Section G costed the reopening at one protocol bump. It is two: the idempotency guarantee had to land before the verbs that depend on it, which is v6's own argument. `README.md` gains rows for v6 and v7. v6's was missing -- the index has been one protocol behind since 11a. Refs: EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
16 KiB
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, <servuo>, net48 / x64, Expansion EJ.
Companion to PLAN.md (1.0 read/event plane), PROTOCOL_2.md (2.0 provisioning + world-state streams), v3.md (3.0 shard content + the visibility framework), v4.md (4.0 guild membership), v5.md (5.0 decay schedule, vendor fees, login result), v6.md (6.0 idempotency, leases, participation), INTEGRATION.md (website API).
Driven by ../website/EVENTS.md and ../website/EVENTS_PLAN.md Phase 12.
1. The payload protocol 6 was the floor for
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 §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.
{"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}
{"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.despawnwould 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. TheHandlesOnMovementfilter applies only to Items —Server/Mobile.cs:3369against: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.
Staticalone accounts for 5031 of the placements under 1992 different graphics, because for that class the graphic is the identity: a barenew 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 §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 §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 §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 §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.