`link/v7.md` gains §11-§14: the two targeted lease planes, the two one-shots, the
routes, and what the build found in already-merged code. `EVENTS.md` §G's five
part-b rows are marked built, three of them carrying a correction. `MODULE_API.md`
records the three contract members 12b amends into 1.10.0. `EVENTS_PLAN.md` has
Phase 12b as built.
THE DESIGN POINT, WRITTEN DOWN
A borrowed value whose home is the world save does not come back on a restart the
way a config value does. 11b's fail-safe -- a lease that never reaches disk makes
a restart a free restore -- depends entirely on the leased value being
memory-only too, and for a spawner property or a seasonal status it is not: a
restart preserves the CHANGE and destroys only the timer that would have undone
it. So those two planes' holds are persisted and their deadlines re-armed, and
the config plane's still are not. The same argument, applied where its premise is
false.
FIVE CORRECTIONS TO EVENTS.md
- `Spawner.Amount` does not exist. The property is `MaxCount`, and
`MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds.
- The seasonal toggle is not "small and safe". Safe, yes -- ServUO does it to
itself from a staff gump -- but `OnStatusChange()` generates or removes world
content for six of the eight permitted types.
- It is a THREE-value enum over nine named events, not a nine-value enum.
(Caught in 12a's survey; the row is corrected here now it is built.)
- `TreasuresOfTokuno` is excluded, because `IsActive()` reads its own `DropEra`
rather than `Status`. A lease on it applies cleanly, reads back, restores
cleanly and changes nothing -- §N10's "capability that lies", and the one
instance no runtime probe can catch.
- The grant row said failure aborts rather than retries. Protocol 6 changed that:
an idempotency key means a repeat is answered by the original reply, so a
retried grant cannot be one winner receiving two.
And the config-lease row is closed at one key. Counted on ServUO 57.4: 156
non-Bridge `Config.Get` call sites, 82 outside a field declaration, all but four
of those inside a `Configure()` or static constructor and cached at boot anyway.
The self-check ships regardless -- it exists for the operator whose OWN scripts
read config live.
MODULE_API 1.10.0, AMENDED IN PLACE
`target` on a lease declaration, `values` on a string lease, and `searchable` +
`{ q }` on option sources. Amended rather than bumped for the reason every phase
since P10 has: 1.10.0 has never reached `main`, so no deployment can tell the
difference, and the cutover is what publishes the whole of it.
The `target` entry records why this extends `core.lease` rather than giving the
module a lease verb of its own: §F settled that in Phase 8, and half its
objection no longer holds (the two-events-one-target refusal comes from the
ledger's unique index whichever verb reserves the row) while the other half --
`maxDurationMs` re-implemented per module -- still does.
§14 records the protocol-pin defect 11a and 12a both shipped, and why the test
that guards it passed anyway: it asserts the three declarations agree with each
other, which all three being equally stale satisfies.
CHECKS
`docs` has no CI. Every relative link in the new sections was resolved by hand;
the files are CRLF in the working tree and each diff is content-sized
(`--numstat` matches the real change), so nothing carries the `\r\r\n` full-file
rewrite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
572 lines
33 KiB
Markdown
572 lines
33 KiB
Markdown
# 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 and 12b)
|
|
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
|
|
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`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 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.
|
|
|
|
---
|
|
|
|
## 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 |
|
|
|
|
**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 `<lease id>#<target>` 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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
## 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 `<UniqueId>` 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.
|