docs(events): the Event System record — the cutover step 16b missed (edge → main)
#232
@@ -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 |
|
||||
|
||||
179
android/PLAN.md
179
android/PLAN.md
@@ -661,6 +661,8 @@ not rank).
|
||||
| Contact | everyone | `/public/contact` |
|
||||
| **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) |
|
||||
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||
| **Events** | everyone, *if core serves events* | `/public/events`, `/public/events/:slug`, `/public/events/series/:slug` (M13) |
|
||||
| **My Events** | signed-in, any role | `/player/events/history` (M13) |
|
||||
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
|
||||
|
||||
Guidelines:
|
||||
@@ -672,6 +674,15 @@ Guidelines:
|
||||
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
|
||||
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
|
||||
a link that briefly `403`s.
|
||||
- **Nor is it the only gate on a MODULE's surfaces (M13).** A feature flag says whether a shard
|
||||
publishes a surface to this viewer; it cannot say whether the module that owns it is installed at
|
||||
all. `GET /public/modules` and core's own `capabilities` in the `version` block answer that, per
|
||||
host rather than per viewer, and an entry may declare a capability alongside its feature. The two
|
||||
compose and both must pass. **The two failure directions differ, and deliberately:** an unknown
|
||||
*feature* answer shows everything, because the server gates every call regardless; an unknown
|
||||
*capability* answer shows everything too, but a **known** one that does not name the capability
|
||||
hides the rows — absence of an answer is not an answer of absence. Without that distinction a site
|
||||
with no `uo` module renders five shard rows that each `404`.
|
||||
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
|
||||
groups and a "Sign in" affordance.
|
||||
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
|
||||
@@ -692,6 +703,11 @@ Guidelines:
|
||||
- **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses).
|
||||
- **Wiki** — list/categories/tags/detail as above.
|
||||
- **Contact** — `POST /public/contact` (rate-limited; handle 429/502).
|
||||
- **Events (M13)** — `GET /public/events` (the calendar; entries are a materialised `run` or a
|
||||
`projected` forecast and say which), `GET /public/events/:slug` (one event, `?run=` selecting
|
||||
which occurrence a results table is about), `GET /public/events/series/:slug` (an arc). Gated by
|
||||
core's `events` capability, not by a module's. A draft, an archived and an *unlisted* definition
|
||||
all answer `404`, indistinguishable from a slug that never existed.
|
||||
|
||||
### 6.2 Public shard (live)
|
||||
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
||||
@@ -1128,6 +1144,169 @@ push, and Play (M6–M8) follow the designed app.
|
||||
rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
|
||||
discarding three 98–100%-covered resolvers. **477 tests green.**
|
||||
|
||||
14. **M13 — Events in the app** (post-v1; scoped 2026-09-08). The website's Event System reached its
|
||||
public surface in events Phase 14a (website#196 / docs#224): a public calendar, event pages, arcs
|
||||
and participation history, plus an `events` capability string in the `version` block. This
|
||||
milestone is **events Phase 14b** — the app's half — and the phase was split on the repo line for
|
||||
the reason 12a/12b was: the app cannot be walked against a page that does not exist yet.
|
||||
|
||||
**Design of record: [`../website/EVENTS.md`](../website/EVENTS.md)** §I and its API surface table,
|
||||
and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 14b. The contract is normative
|
||||
there; this entry records what the app does about it.
|
||||
|
||||
**No backend work.** All four routes are live on the website's `edge`:
|
||||
`GET /public/events`, `/public/events/:slug` (with `?run=`), `/public/events/series/:slug`, and
|
||||
`GET /player/events/history`. `MODULE_API_VERSION` is untouched — nothing about the module
|
||||
contract changes here.
|
||||
|
||||
#### The prerequisite, which is in this milestone and not after it
|
||||
|
||||
**The app cannot tell a module that is not installed from a lookup that failed, and it renders a
|
||||
broken drawer because of it.** `ShardFeaturesRepository.refresh()` maps *every* failure to `null`
|
||||
and `canSee(null, …)` answers `true`, so on a site with no `uo` module — which is any site running
|
||||
a different game — `GET /public/shard/features` 404s, all five shard rows render, and every one of
|
||||
them 404s when tapped. Failing open is right *within* a module that is installed (an admin may
|
||||
switch a surface off between two refreshes, and the server gates every call regardless) and wrong
|
||||
for one that is not there at all.
|
||||
|
||||
Three decisions, taken by the org lead on 2026-09-08:
|
||||
|
||||
- **Absence of an answer is not an answer of absence.** A *successful* read of
|
||||
`GET /public/modules` that does not name a capability **hides** the rows that need it — that is
|
||||
an answer. A read that *failed* keeps the last answer this host gave, and when the host has
|
||||
never answered the gate stays open and the drawer renders as it does today. The bug is that the
|
||||
current code collapses those three into one `null`.
|
||||
- **The capability gate composes with the feature gate; it does not replace it.** They answer
|
||||
different questions and both must pass. `capability` asks *is this module installed at all*, and
|
||||
is per-host — it changes when an operator installs or removes a module. `feature` asks *does this
|
||||
shard publish this surface to this viewer*, and is per-viewer — it changes on sign-in. That is
|
||||
also why the two refresh on different clocks: capabilities with the appearance (per host),
|
||||
features with the session (per viewer).
|
||||
- **One capability gates all five shard rows: `shard`.** module-uo also declares `atlas`, `market`,
|
||||
`champs`, `guilds`, `governors`, `houses` and `cliloc`, and gating each row on its own string
|
||||
would be a second, worse copy of what `feature` already decides. The two new events rows gate on
|
||||
core's own `events`.
|
||||
|
||||
Core's list and a module's are **separate lists**, deliberately, and the app reads both:
|
||||
`capabilities` in the `version` block of `GET /public/status` for core, and `GET /public/modules`
|
||||
for the installed modules. See [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.9 — an
|
||||
unknown string is treated as absent, and **no route may be inferred from one**.
|
||||
|
||||
#### The website path → route table was stale, and that is a second live defect
|
||||
|
||||
Found while scoping this milestone, and **fixed here** rather than logged (org lead, 2026-09-08).
|
||||
[`NavPaths.kt`](#) quotes the website's `export const NAV` verbatim as sixteen rows. Since the
|
||||
module-system cutover on 2026-08-12 core's `NAV` is **eight** rows and the nine shard rows belong
|
||||
to module-uo, which registers them at `/uo/shard`, `/uo/champs`, `/uo/guilds`, `/uo/governors`,
|
||||
`/uo/houses`, `/uo/rules`, `/uo/atlas`, `/uo/leaderboards` and `/uo/market` — core mounts a
|
||||
module's public pages at `/<id>/<path>`. Three things follow, all of them true of the shipped app:
|
||||
|
||||
- a nav override an admin sets on any shard row is **ignored**, because the merge intersects with
|
||||
the app's own menu through a table that no longer resolves those paths;
|
||||
- an added link to a shard page **hands off to a Custom Tab** instead of opening natively, and
|
||||
`/uo` alone resolves to a CMS page that does not exist;
|
||||
- `WEB_ROUTE_ORDER` is the number line a stored `order` is compared against, and a sixteen-row
|
||||
line against a nav that now numbers core 0–7 and appends the module's rows after it puts a
|
||||
partly-overridden drawer in the wrong order. `/site/events` is a new core row at index 2, so
|
||||
every row after News shifts.
|
||||
|
||||
**The nine `/uo/*` paths are hardcoded, and documented as coupling to one module** (org lead). The
|
||||
alternative — deriving `/<id>/…` from the module list — infers a route from a capability, which
|
||||
§2.9 forbids, and would hardcode the same path shape less visibly. A site running a different game
|
||||
module maps none of the nine and its links hand off to the browser, which is the correct answer.
|
||||
|
||||
#### What ships
|
||||
|
||||
Four screens, one per 14a surface, and the arc is included because the event page links to it —
|
||||
omitting it would make that link a dead end or a hand-off out of a native screen:
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Events** (the calendar) | `events` | `GET /public/events` |
|
||||
| **Event** | `events/{slug}?run={run}` | `GET /public/events/:slug` |
|
||||
| **Arc** | `events/series/{slug}` | `GET /public/events/series/:slug` |
|
||||
| **My Events** | `account/events` | `GET /player/events/history` |
|
||||
|
||||
- **The calendar is a list, and a projection is drawn differently from a run.** Both are §I's
|
||||
rules, one tier along: an entry past the materialisation horizon is arithmetic with nothing
|
||||
committed to it, so it is labelled rather than drawn as a booking.
|
||||
- **The day heading is the reader's, the time beside it is the event's.** The split is §I's and it
|
||||
is the one thing about event times that is easy to get wrong. A player in Berlin told an American
|
||||
shard's event is at 02:00 has been told something true and useless.
|
||||
- **The status word follows the clock, not the status.** The server publishes `failed` and `missed`
|
||||
as `cancelled`; *did not happen* is right for a past occurrence and false for a future one, and a
|
||||
run four days out that an operator called off is the common case. This is 14a's own defect, and
|
||||
the app must not re-introduce it in Kotlin.
|
||||
- **`?run=` is carried, not dropped.** The page lives at the definition's slug so a weekly event
|
||||
has one address, and the occurrence is in the query.
|
||||
|
||||
**My Events is one drawer row at `SIGNED_IN`, not two** (org lead). The website mounts
|
||||
participation history twice — `/account/events` and `/admin/events/mine` — only because
|
||||
`RequirePlayer` guards `/account` and the route behind it is role-agnostic. The app's drawer has no
|
||||
such wall: `GET /player/events/history` is `requireAuth` alone and self-scoped on the caller's own
|
||||
id, so one row serves players and staff alike. It is deliberately **not** at `PLAYER` access, which
|
||||
would imply event history is game-linked data; it is not, and a shard with no game module still has
|
||||
events.
|
||||
|
||||
#### An inbox link to an event opens the app, not the browser
|
||||
|
||||
**Departing from the recommendation** (org lead, 2026-09-08). Engagement Phase 8's contract is that
|
||||
an inbox item's `url` is site-relative and hands off to a Custom Tab. Phase 14a gave the six public
|
||||
`event.` triggers an `eventUrl` of exactly the form `/site/events/<slug>?run=<id>`, so an inbox row
|
||||
about an event now has a native destination and should use it.
|
||||
|
||||
The mechanism is the one that already exists — `resolveWebPath` — rather than a second one. That
|
||||
forces one rule to change, and it is worth stating because it is the exact trap the rule was
|
||||
written to avoid: **a path carrying a query hands off**, because no app route took one and a
|
||||
native match would silently drop what the admin wrote. The event page is the first app route that
|
||||
takes a query, so the rule becomes *a query hands off, except `run` on an event page*. Anything
|
||||
else in the query string, and any fragment, still hands off untouched.
|
||||
|
||||
- **Excluded**, in the same class as M10's, M11's and M12's exclusions: the events **authoring**
|
||||
surface. The app does not gain an event editor, a run console or the cap meter; Phase 13 built
|
||||
those for the web and they are admin *configuration*, which the app is a consumer of and not an
|
||||
editor for.
|
||||
|
||||
**`account/events`, not `events/mine`**, and the reason is a bug one tier along: `events/mine` and
|
||||
`events/{slug}` are both two segments, and a static-versus-argument race between two NavHost
|
||||
patterns is exactly what events Phase 13 found in React Router, where a static `events/new`
|
||||
outranked `events/:id` and made creating an event impossible for seven phases. Under `account/`
|
||||
there is no dynamic sibling and no race to lose.
|
||||
|
||||
**Verified:** the app suite (**570 tests, 0 failures**), and an emulator walk against a local
|
||||
website running the 14a code, at three rungs — anonymous, player and admin.
|
||||
|
||||
#### The walk found three defects, and 563 green tests found none of them
|
||||
|
||||
The rig's own site had **no `uo` module installed**, which made it the exact case the prerequisite
|
||||
fix is for, and is why two of the three were visible at all.
|
||||
|
||||
- **The three player game-data rows were not gated.** `My characters`, `My vendors` and `My houses`
|
||||
read `/player/shard/*` — the same module's *player* mount — and rendered on a backend with no
|
||||
module, answering *"This content couldn't be found"* with a Retry button. The gate had been
|
||||
applied to the five public shard rows only. The test that was meant to catch this asked whether
|
||||
every row **with a `feature`** declared the capability, which is true and insufficient: these
|
||||
three carry no feature, because they are gated by ownership rather than by the visibility
|
||||
framework. It now asks by **route**.
|
||||
- **`score` is `DECIMAL(18,4)`, and declaring it an integer breaks the whole response.** The first
|
||||
history row the walk read scored `318.5`. kotlinx does not round a fraction into a `Long` — it
|
||||
refuses the entire body, so a `200` reached the screen as *"Something went wrong on the server."*
|
||||
The same field is on the public results table, so **any** event whose module scores fractionally
|
||||
would have broken the event page for every anonymous visitor.
|
||||
- **A drawer route's view model outlives a sign-out, and this one is per-account.**
|
||||
`navigateTopLevel` saves and restores back-stack state, so the entry keeps its `ViewModelStore`
|
||||
and a view model that loads only in `init` never runs again. Signing out of the admin and back in
|
||||
as a player showed the **player the admin's participation history**, with no request made at all.
|
||||
It now keys on the signed-in account id, and the shell sends a signed-out caller home rather than
|
||||
letting the screen ask and render a 401.
|
||||
|
||||
What the walk **confirmed working**: every shard row absent on a module-less backend at all three
|
||||
rungs while Events remained; a cancelled occurrence three days out reading *Cancelled* rather than
|
||||
*Did not happen*, on both the calendar and the event page; projections labelled; the event's own
|
||||
zone beside the reader's own day; an arc; `?run=` surviving the NavHost pattern to reach the server;
|
||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||
participation history self-scoped, proved by two accounts rather than asserted.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||
|
||||
@@ -21,6 +21,12 @@ None of these are *moderation*. A staff member who wants to kick a cheater, ban
|
||||
|
||||
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
|
||||
|
||||
> ⚠ **Amended 2026-09-01 — see [§8a](#8a-amendment--2026-09-01).** That thesis holds for *ad-hoc staff
|
||||
> action*, and the Event System does not contest it: a GM still does the target-driven parts in-client.
|
||||
> What was reopened is narrower — declared, cap-bounded, ledgered, idempotent creation and **leased**
|
||||
> mutation, both owned by an event run and both undone at teardown. `[set` / `[get` / `[add` and
|
||||
> loot-table changes stay excluded exactly as this paragraph argues.
|
||||
|
||||
---
|
||||
|
||||
## 2. How ServUO admin controls actually work
|
||||
@@ -284,6 +290,11 @@ All resolved by source inspection (ServUO checkout at `<servuo>`). No live-shard
|
||||
|
||||
## 8. Decisions — locked 2026-07-12
|
||||
|
||||
> ⚠ **Partly amended on 2026-09-01. Read [§8a](#8a-amendment--2026-09-01) with this section.** Two of
|
||||
> the exclusions in the first bullet below — world creation, and item/gold grants — were reopened for
|
||||
> the Event System under conditions that did not exist when this was locked. The bullet is left
|
||||
> standing verbatim because it is the decision that was actually taken; §8a is what changed and why.
|
||||
|
||||
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
|
||||
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
|
||||
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
|
||||
@@ -292,6 +303,70 @@ All resolved by source inspection (ServUO checkout at `<servuo>`). No live-shard
|
||||
|
||||
---
|
||||
|
||||
## 8a. Amendment — 2026-09-01
|
||||
|
||||
**What changed:** §8's blanket exclusion of world writes is narrowed, in **two** places, and only for
|
||||
the Event System. Everything §8 decided about the moderation write plane — its verbs, its
|
||||
CoOwner-level authority, its Owner-only shield, its `Bridge.cfg` default-off switch, its attribution
|
||||
and its logging — stands unchanged.
|
||||
|
||||
**Decided by:** the org lead, 2026-09-01, answering
|
||||
[`../website/EVENTS.md`](../website/EVENTS.md) §N1. The design of record is `EVENTS.md`; this section
|
||||
records the reversal against the decision it reverses, so §8 is never read alone.
|
||||
|
||||
### What is now permitted
|
||||
|
||||
| | Permitted | Under what conditions |
|
||||
|---|---|---|
|
||||
| **1** | **Event-owned creation** — creatures, "simple" boss variants, oracle NPCs, temporary gates, temporary decoration | Created by an event run, counted against a module-declared cap dimension, recorded in the resource ledger with the run, step and actor that produced it, and **deleted by serial at teardown** on every terminal path — completion, cancellation and abort alike |
|
||||
| **2** | **Leased mutation** of a plugin-declared allowlist of config values and object properties | Baseline recorded before the write, drift-checked on read-back, and **deadline-enforced by the game itself** — the shard restores baseline when the lease expires whether or not the website ever comes back |
|
||||
| **3** | **Item and gold grants** | As (1): cap-bounded, ledgered and attributable — but `reversible: 'none'`. A granted item is not clawed back |
|
||||
|
||||
### What stays excluded, permanently
|
||||
|
||||
Unchanged by this amendment and not reopened: **loot-table changes**, and **arbitrary `[set` / `[get`
|
||||
/ `[add`**. Both are the "change something with no baseline recorded" case, which is the thing §8 was
|
||||
actually objecting to; no cap makes them safe. `firewall`, `kill`/`res`, `jail` and `set-access-level`
|
||||
also stay cut — this amendment is about what an *event* may create and borrow, not about restoring the
|
||||
Tier-H moderation verbs.
|
||||
|
||||
### Why the reversal, and why item grants are called out separately
|
||||
|
||||
**§8's objection was to an unrecorded change with a large blast radius, made from a web form.** It was
|
||||
locked when the bridge was a read plane with three inbound verbs, none of which were moderation, and
|
||||
when the only way to express "change the world" would have been a passthrough command string. In that
|
||||
setting the objection was correct and it would still be correct today for anything shaped that way —
|
||||
which is exactly why `[set` / `[get` / `[add` remain excluded.
|
||||
|
||||
**The Event System proposes something with four properties that shape did not have**, all of which are
|
||||
required before P12 ships and none of which are optional:
|
||||
|
||||
1. **Declared, not typed.** A module declares a verb with its params and their types; the browser posts
|
||||
an action id and a params object. There is no passthrough field and no place a request body can name
|
||||
a sidecar route.
|
||||
2. **Cap-bounded.** Every dimension is enforced in a conditional `UPDATE` with the cap in the `WHERE`
|
||||
clause — a control a stolen admin session cannot talk its way past, because a role check is exactly
|
||||
what a stolen session has already passed.
|
||||
3. **Ledgered.** Every created object and every borrowed value is recorded in `event_run_resources`
|
||||
before it is confirmed, with its run, step and actor, and with a declared revert.
|
||||
4. **Idempotent on the wire.** A key minted once per step rides core → module → sidecar → plugin, and
|
||||
the plugin answers a repeat with the original result rather than acting twice — so a lost
|
||||
acknowledgement cannot double a spawn or a grant.
|
||||
|
||||
**Item grants are named as their own row because they are a second reversal, not a consequence of the
|
||||
first.** §8's cut list names "item/gold grants" explicitly and separately from world building; reading
|
||||
the creation reopening as carrying grants along with it would be the silent reversal this amendment
|
||||
exists to avoid. They are permitted on the same four conditions as creation — with the difference that
|
||||
they are **not reversible**, which is why the cap and the ledger carry the entire weight for this row
|
||||
and why the action ships declared as `reversible: 'none'` rather than pretending at a revert.
|
||||
|
||||
**What has not changed:** the module boundary is still not a security boundary, and none of the above
|
||||
defends against a hostile module. It defends against a compromised session and an operator mistake —
|
||||
both of which *scheduling* makes larger, because a scheduled change happens while nobody is watching.
|
||||
That is the reason the caps and the leases matter more here than the role check does.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|
||||
@@ -55,20 +55,26 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
|
||||
|
||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||||
|
||||
The current version is **5**, shipped on 2026-09-01 in sidecar **v2.1.0** and overlay **v1.1.0** — resolve them as bundle **2026.09.01**, never as "latest of each". The pairing before it was protocol 4, sidecar **v2.0.0** + overlay **v1.0.0**.
|
||||
The current version is **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each".
|
||||
|
||||
- Every response carries an **`X-UOLink-Version: 5`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
|
||||
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
- Every response carries an **`X-UOLink-Version: 6`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
|
||||
- **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```json
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" }
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
|
||||
```
|
||||
|
||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||
|
||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
||||
|
||||
**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying
|
||||
an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds
|
||||
`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six
|
||||
endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an
|
||||
operator has not switched it on. See [`v6.md`](v6.md).
|
||||
|
||||
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
|
||||
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
|
||||
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
|
||||
@@ -97,6 +103,21 @@ both about ABSENCE rather than presence, because both are easy to read as an err
|
||||
- `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a
|
||||
commission vendor. Neither means "this vendor has no money".
|
||||
|
||||
**v6 (Protocol 6)** is the first bump that is mostly about a **guarantee** rather than about data
|
||||
([`v6.md`](v6.md)). A command may now carry an **`idempotencyKey`**, and the shard promises to
|
||||
execute a key **at most once**: a repeat is answered with the original reply rather than re-run. That
|
||||
is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a
|
||||
command that never applied were the same event as seen from the caller. See §6's write plane for how
|
||||
to send one, and §7 for the one new status code it introduces.
|
||||
|
||||
It also adds **`champ.boss.killed`**, a champion's defeat with the damage table only the shard ever
|
||||
sees. Previously this was inferable from `champ.update` losing its `bossUp` alongside a nearby
|
||||
`mob.killed` — a signal that also fires when a GM resets a spawn and that says nothing about who did
|
||||
the work.
|
||||
|
||||
Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key
|
||||
behaves exactly as it did under v5.
|
||||
|
||||
**Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a
|
||||
client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a
|
||||
closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you
|
||||
@@ -295,6 +316,7 @@ Champion spawns have no in-game event either, so they're polled (`ChampSweepSeco
|
||||
|------|--------|-------|
|
||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||||
| `champ.boss.killed` (Protocol 6) | `category`, `bossSerial`, `boss`, `bossType`, `map`, `x`,`y`,`z`, `region`, `killer`, `damagers` — plus `serial`, `type`, `level` naming the ALTAR when the kill could be attributed to one | The boss went down. A real event, not a polled diff — see below. |
|
||||
|
||||
`status` is one of:
|
||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||||
@@ -322,6 +344,43 @@ Category-specific fields on `champ.update`:
|
||||
|
||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||||
|
||||
##### `champ.boss.killed` (Protocol 6)
|
||||
|
||||
The one champion frame that is **not** polled. It fires on the death itself, so unlike everything
|
||||
above it is an event rather than a difference between two snapshots — which means a first sighting
|
||||
of it is the thing being reported rather than a baseline to compare against.
|
||||
|
||||
```json
|
||||
{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar",
|
||||
"bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain",
|
||||
"serial":"0x400150E8","type":"Abyss","level":0,
|
||||
"killer":{"serial":"0x2E0","name":"tester","player":true},
|
||||
"damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240},
|
||||
{"serial":"0x24C","name":"Darrow","player":true,"damage":120}],
|
||||
"t":1788551315000}
|
||||
```
|
||||
|
||||
- **`serial` means the ALTAR here**, matching `champ.update`, so the two join without a rule about
|
||||
which of two serials means what. It is **absent** — with `type` and `level` — when the boss could
|
||||
not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval.
|
||||
The kill is still reported; it simply arrives without its altar.
|
||||
- **`damagers` is every player who damaged it, highest first**, each the standard actor object plus
|
||||
a `damage` total. Totals are summed per player, so nobody appears twice. Entries are included
|
||||
whether or not the shard still considers them valid for **looting rights** — someone who fought
|
||||
two thirds of the fight and then died took part in it. Capped at 20.
|
||||
- **`region` is the nearest NAMED region** and is **absent** in open countryside. It is not the most
|
||||
specific region containing the boss: an active champion altar registers an unnamed region of its
|
||||
own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a
|
||||
name", never "the shard would not say".
|
||||
- `category` is `champion` or `sea`. There is no `mini` — a `MiniChamp` has no boss.
|
||||
|
||||
**This does not replace `champ.remove`.** A slain `sea` boss still produces one, because it also
|
||||
leaves the board. A `champion` altar stays on the board and goes to `cooldown` as usual.
|
||||
|
||||
**`damagers` names players and ranks them.** The sidecar serves it verbatim, as it serves
|
||||
everything; deciding who may see a damage table is the consuming site's job. The website's own
|
||||
answer is `staff` by default with the kill itself public — see [`v6.md`](v6.md) §4.
|
||||
|
||||
#### Guilds (Protocol 2.0)
|
||||
|
||||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
||||
@@ -878,6 +937,128 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web
|
||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
||||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
||||
|
||||
### Retrying a command safely — `idempotencyKey` (Protocol 6)
|
||||
|
||||
Any command in this section may carry an **`idempotencyKey`**, and the shard promises to execute a
|
||||
key **at most once**. A repeat is not re-run: it is answered with the **original reply**, restamped
|
||||
with the repeat's own correlation id and marked `"replayed": true`.
|
||||
|
||||
```json
|
||||
POST /admin/broadcast
|
||||
{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" }
|
||||
|
||||
→ 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 }
|
||||
→ 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true }
|
||||
```
|
||||
|
||||
Note the second reply's `t`: it is the **first** attempt's, because it is the stored answer rather
|
||||
than a fresh execution. The world write happened once.
|
||||
|
||||
This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement
|
||||
and a command that never applied are the same event as seen from here, and the only safe policy is
|
||||
to give up on the announcement rather than risk sending it twice.
|
||||
|
||||
Four rules for a caller:
|
||||
|
||||
- **A key belongs to your unit of work, not to the attempt.** Derive it from something stable — the
|
||||
website's event runner uses `sha256(runId|stepId)` — so every retry of one action carries the
|
||||
same key and a different action never collides with it. A fresh value per call satisfies the field
|
||||
and defeats the entire mechanism.
|
||||
- **A key is remembered for one hour**, bounded at 4096 keys per shard. Retry inside that window.
|
||||
- **A repeat that arrives while the original is still in flight** is answered **425 Too Early**
|
||||
(`{"kind":"bridge.busy"}`). Nothing ran; come back. It is transient by construction.
|
||||
- **A replayed reply is an ordinary 200.** Treat it exactly as you would have treated the answer you
|
||||
lost; `replayed` is for your log.
|
||||
|
||||
Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a
|
||||
human just pressed a button for and can see the result of.
|
||||
|
||||
The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the
|
||||
second removal of an entry is a no-op the shard is already happy to perform.
|
||||
|
||||
### The event plane — leases (Protocol 6)
|
||||
|
||||
**Off by default.** Every endpoint below answers **403** unless the operator has set
|
||||
`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch:
|
||||
enabling admin writes is consenting to staff moderation from a screen a human is looking at, and
|
||||
enabling this is consenting to your world being changed and watched on a schedule, unattended.
|
||||
|
||||
A **lease** is a live configuration value held at a new setting for a bounded time. The shard
|
||||
restores the baseline when the deadline passes **whether or not you are ever heard from again** —
|
||||
so the worst case is a world back at baseline early, never one stuck changed indefinitely.
|
||||
|
||||
```json
|
||||
GET /lease
|
||||
→ 200 { "kind":"lease.list.ok", "leases":[
|
||||
{ "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float",
|
||||
"min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] }
|
||||
|
||||
POST /lease
|
||||
{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000,
|
||||
"untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" }
|
||||
→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 }
|
||||
|
||||
POST /lease/release
|
||||
{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" }
|
||||
→ 200 { "kind":"lease.ok", "released":true, "current":"1000" }
|
||||
```
|
||||
|
||||
Five things a caller needs:
|
||||
|
||||
- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take
|
||||
effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply
|
||||
cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and
|
||||
"any config key" is not offered.
|
||||
- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline
|
||||
is measured against two clocks, and a shard whose clock runs fast would restore your lease the
|
||||
moment it took it.
|
||||
- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed
|
||||
values at the other end; the text is so a compare-and-set is comparing what you sent.
|
||||
- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you
|
||||
held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an
|
||||
error: the mechanism did its job, and only a human can decide what should happen next.
|
||||
- **`held` means the shard still has a record of the lease, not that the value is still overridden.**
|
||||
A lease whose deadline has fired stays listed with `expired: true` until you release it, so a
|
||||
reconcile in that window does not read a working backstop as a lost resource. A shard restart, by
|
||||
contrast, reverts every lease and clears the record — `held: false` is how you learn that.
|
||||
|
||||
### The event plane — participation (Protocol 6)
|
||||
|
||||
A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed
|
||||
by **character serial**. The shard computes a score and you store it; the components ride along so
|
||||
you can explain it.
|
||||
|
||||
```json
|
||||
POST /participation
|
||||
{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 }
|
||||
→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false }
|
||||
|
||||
POST /participation/99/snapshot
|
||||
{ "idempotencyKey":"…" }
|
||||
→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5,
|
||||
"participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17",
|
||||
"seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000",
|
||||
"firstMs":1788550182074, "lastMs":1788553782074 } ] }
|
||||
|
||||
POST /participation/99/close
|
||||
→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 }
|
||||
```
|
||||
|
||||
- **The area is a point and a radius, not a region name.** The most specific region containing an
|
||||
event is routinely anonymous on a UO map — an active champion spawn registers a nameless region
|
||||
over its own area — so a region-named venue would be undeclarable for exactly the places events
|
||||
happen.
|
||||
- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended
|
||||
run the shard walks its members across game ticks rather than in one call, so a repeat arriving
|
||||
mid-walk is answered **425**. Come back.
|
||||
- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run
|
||||
the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being
|
||||
counted for it either way.
|
||||
- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally
|
||||
says so rather than quietly being short.
|
||||
- **`webId`** is present only where the character's game account is linked to a website user. Most
|
||||
characters carry none; treat its absence as ordinary.
|
||||
|
||||
### Help-page (support) queue
|
||||
|
||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
||||
@@ -1073,8 +1254,10 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
| 200 | OK |
|
||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||||
| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard |
|
||||
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) |
|
||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||||
| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry |
|
||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||||
| 500 | Internal error (e.g. database) |
|
||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
||||
@@ -1082,6 +1265,11 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
|
||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
||||
|
||||
`425` vs `409`: both are conflicts of a sort and they want **opposite** responses. A `409` is a
|
||||
deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will
|
||||
never help. A `425` is a retry that will succeed on its own. They are deliberately different codes
|
||||
so a retry loop cannot quietly swallow a mismatched deployment.
|
||||
|
||||
---
|
||||
|
||||
## 8. Putting it together
|
||||
@@ -1089,7 +1277,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
59
link/PLAN.md
59
link/PLAN.md
@@ -178,10 +178,16 @@ Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanL
|
||||
| Player death | `PlayerDeath` | low | |
|
||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. **Protocol 6 uses it for `champ.boss.killed`**: the handler's first act is to decide this is not a champion, by type, which is one `is` check per death. |
|
||||
| **Damage table at a kill** | `Mobile.DamageEntries`, read inside `CreatureDeath` | — | Not a stream: the accumulated per-attacker totals, readable **only at the death**, since ServUO discards them with the creature. This is what makes a boss kill attributable without a per-hit event. |
|
||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
||||
|
||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
||||
>
|
||||
> But per-*kill* attribution needs no override at all, and Protocol 6 is where that was noticed:
|
||||
> `Mobile.DamageEntries` already holds a running total per attacker, and a `CreatureDeath` handler
|
||||
> reads it before the creature is disposed of. A whole participation signal, for free, at the one
|
||||
> moment it is worth having.
|
||||
|
||||
### 5.6 Progression & activity
|
||||
|
||||
@@ -295,6 +301,57 @@ Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||||
|
||||
Every inbound handler marshals to the Core thread before touching world state.
|
||||
|
||||
### Any inbound command may carry an `idempotencyKey` (Protocol 6)
|
||||
|
||||
```jsonc
|
||||
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"…","idempotencyKey":"5f2c…"}
|
||||
```
|
||||
|
||||
The shard executes a key **at most once** and answers a repeat with the original reply, restamped
|
||||
with the repeat's correlation id and marked `"replayed": true`. That is what makes a world-writing
|
||||
command safe to retry after a lost acknowledgement, and it is a precondition for every world verb
|
||||
the event system adds later — see [`v6.md`](v6.md) §2.1.
|
||||
|
||||
The gate sits in `BridgeBoot`'s inbound dispatch rather than in each handler, so it covers every
|
||||
kind including ones a later protocol adds. A command with no key behaves exactly as it did before.
|
||||
One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still
|
||||
in flight, nothing ran, come back.
|
||||
|
||||
### The event plane, and the Bridge's first persisted state (Protocol 6)
|
||||
|
||||
```jsonc
|
||||
{"kind":"lease.apply","key":"PlayerCaps.SkillCap","value":"1200","holdMs":600000,"runId":"77"}
|
||||
{"kind":"participation.open","runId":"99","map":"Felucca","x":1496,"y":1628,"radius":40}
|
||||
```
|
||||
|
||||
Six commands behind one new gate, `Bridge.EventsEnabled`, default off and deliberately not the admin
|
||||
write plane's switch. Two facts about ServUO shaped both halves and are worth recording here rather
|
||||
than only in the spec.
|
||||
|
||||
**`Server/Config.cs` is a real runtime store, and almost nothing reads it live.** `Config.Set`
|
||||
mutates the in-memory entry table and `Config.Load()` is guarded by `_Initialized`, so a Set survives
|
||||
every later Get — but of the **158** non-Bridge `Config.Get` call sites in `Scripts/`, roughly
|
||||
**eight** are read at the call site. The rest are cached at type initialisation, where a lease
|
||||
applies cleanly and does nothing at all. That is why the lease catalog is a verified allowlist and
|
||||
never "any config key".
|
||||
|
||||
Two consequences, both deliberate: **nothing calls `Config.Save()`**, so a lease never reaches disk
|
||||
and a shard restart is a free restore; and **`Config.Set` has exactly one caller in the whole tree**
|
||||
(`Server/ScriptCompiler.cs`), so on a stock shard no GM can drift a configuration lease even
|
||||
deliberately — the compare-and-set is still required for Phase 12's object-property leases, and
|
||||
proving it needs the scaffolding driver.
|
||||
|
||||
**The participation ledger is the first thing this plugin has ever persisted.** A run spans hours and
|
||||
a restart mid-event is ordinary, so an in-memory tally would silently regress every attendee's score.
|
||||
`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the
|
||||
world save — no persistence *item*, so no world object and nothing for a GM to delete by accident.
|
||||
The hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and
|
||||
`Initialize()` is too late.
|
||||
|
||||
**And the first handler that defers.** `participation.snapshot` walks a large run's members across
|
||||
Core ticks rather than in one inbound call, which makes it the first handler to complete after
|
||||
`OnInboundLine` returned — and therefore the first that can actually produce `bridge.busy`.
|
||||
|
||||
### `server.hello` is per-connection, not per-boot
|
||||
|
||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
||||
|
||||
649
link/v6.md
Normal file
649
link/v6.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Protocol 6 — A guarantee, and the kind that pays for it
|
||||
|
||||
**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7.
|
||||
**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b)
|
||||
**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), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||
**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this bump is not like the other five
|
||||
|
||||
Every protocol version so far added **data**: a field, a frame, a board. This one adds a **promise**,
|
||||
and the data it also carries is along for the ride because the ride is expensive.
|
||||
|
||||
The promise is one sentence:
|
||||
|
||||
> **A command carrying an `idempotencyKey` is executed at most once. A repeat is answered with the
|
||||
> original reply.**
|
||||
|
||||
Nothing on the wire could say that before, and [`EVENTS.md`](../website/EVENTS.md) §A row 7 records
|
||||
what that cost: *"a command whose ack is lost is indistinguishable from one that never applied."*
|
||||
The website's event runner retries a step that did not come back — that is what a runner is for —
|
||||
so every world-writing verb had to be declared un-retryable, and Phase 9 shipped `uo.broadcast`
|
||||
answering `retry: false` to *everything*, including a 503 from a shard that was merely restarting.
|
||||
The reasoning was sound and the trade was real: a lost announcement is cheaper than a doubled one.
|
||||
|
||||
It is not a trade that survives contact with the rest of the workstream. An event that spawns
|
||||
creatures, grants an item, or holds a lease on a live config value cannot be built on a wire where
|
||||
"did that apply?" has no answer. §G's capability table says so in as many words: *"Required before
|
||||
any world write ships."*
|
||||
|
||||
**So the guarantee had to come before the verbs, and this is the version that has nothing else to
|
||||
do.** Protocol 12's world verbs are the payload; protocol 6 is the floor they stand on.
|
||||
|
||||
### 1.1 Why `champ.boss.killed` rides along
|
||||
|
||||
The same argument v5 §1 made: a bump costs a sidecar release, a republished bundle and an operator
|
||||
update on every shard, so a field left out costs a *second* bump rather than a follow-up commit.
|
||||
|
||||
`champ.boss.killed` is the one piece of new data the events workstream is known to need and can
|
||||
state now. §G's capability table had a boss defeat as 🔗 — *inferable* from `champ.update` going
|
||||
`bossUp` true then false, correlated against a nearby `mob.killed` — and called that
|
||||
*"workable and slightly fragile"*. It is more fragile than that: `bossUp` also drops when a GM
|
||||
resets a spawn, when a boss despawns, and after a sidecar reconnect clears the sweep's diff cache.
|
||||
And the inference is silent about the thing an event most wants to know, which is **who fought it**.
|
||||
|
||||
A phase condition is written over a trigger firing, so a kind nothing declares is a kind no event
|
||||
can wait on. *"Advance when the boss falls"* is the canonical UO event phase boundary, and it did
|
||||
not exist.
|
||||
|
||||
---
|
||||
|
||||
## 2. The shard side
|
||||
|
||||
### 2.1 The idempotency key
|
||||
|
||||
**One new optional field on every inbound command**, and no new command kinds:
|
||||
|
||||
```json
|
||||
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"...",
|
||||
"idempotencyKey":"5f2c…40 hex"}
|
||||
```
|
||||
|
||||
The gate is in `BridgeBoot.OnInboundLine`, before the handler is dispatched, so it covers **every**
|
||||
inbound kind including the ones a later protocol adds — which is the half that is easy to forget. A
|
||||
command with no key behaves exactly as it did before protocol 6, which is what leaves the admin
|
||||
screens unchanged: a human pressing a button can see whether the thing happened, and sends none.
|
||||
|
||||
The store is `BridgeIdempotency`. Four rules, each of which is a decision rather than an
|
||||
implementation detail.
|
||||
|
||||
**1. Reserve on receipt, not on completion.** The key is recorded *before* the handler runs. A
|
||||
handler that finishes inside its own inbound call can never see a repeat — the Core thread processes
|
||||
one line at a time — but a handler that defers (a lease that arms a timer, a spawn that waits for a
|
||||
save) completes long after `OnInboundLine` has returned, and that is precisely the window a lost
|
||||
acknowledgement opens. Reserving late would leave it uncovered.
|
||||
|
||||
A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, and the caller is
|
||||
told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work
|
||||
is happening.
|
||||
|
||||
**2. A key that has begun is never released — except on a refusal.** Not when the handler throws.
|
||||
Releasing it would let a retry re-run a command that may have applied half of itself, which is the
|
||||
exact failure this file exists to prevent. A handler that throws stores a `bridge.error` reply
|
||||
instead, so the retry gets a definite answer and the step fails once rather than looping.
|
||||
|
||||
**A REFUSAL is the third case**, added by the Phase 16 acceptance walk and amending protocol 7 in
|
||||
place. A handler that ran to completion and answered `*.error` did not do anything — every refusal
|
||||
on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that
|
||||
failed and left the value alone. Remembering it froze the answer for ever, so a refusal that
|
||||
*waiting fixes* could never be retried past. `uo.world.save` is the case that found it: the shard
|
||||
saves at most every 300 seconds, the module documents that as "the one refusal on this plane that
|
||||
waiting fixes", and six attempts over four minutes all replayed one frozen sentence — "the last save
|
||||
was 227 seconds ago" — because the number was the first reply's, not the clock's. A step's key is
|
||||
one value for the life of the step, so the operator's retry control could not escape it either.
|
||||
|
||||
So a refusal releases the key: nothing happened, and the caller may ask again. The refusal is still
|
||||
**emitted** to the caller, which is what ends that attempt; it is simply not remembered as the key's
|
||||
answer. A refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a handler
|
||||
family added later is covered without extending a list. `bridge.error` is excluded deliberately —
|
||||
that is the reply the shard writes when a handler THREW, which is the case whose key must be kept.
|
||||
|
||||
**This puts a rule on handlers, and it is the rule the release rests on: do not answer `*.error`
|
||||
after changing the world.** Report a partial change in an `ok` reply, as `item.grant` does with
|
||||
`granted`/`missed` and `world.despawn` with `removed`/`gone`/`refused`. The shard cannot verify
|
||||
"nothing happened"; it takes the `.error` kind as the claim.
|
||||
|
||||
**3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh
|
||||
per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the
|
||||
original id verbatim would leave the call hanging until the reply timeout — the very failure being
|
||||
answered. The stored reply is re-emitted with its correlation field rewritten and `"replayed": true`
|
||||
appended; everything else is byte-for-byte the original, including its original `t`.
|
||||
|
||||
`reqId`, `code` and `id` are all recognised, in the order `rpc.rs` tries them. A repeat that
|
||||
correlates on a *different* field than the original did is refused with a `bridge.error` rather than
|
||||
answered under an id nobody is waiting on.
|
||||
|
||||
**4. The bound is loud.** TTL one hour, cap 4096 keys, drop-oldest. The hour is not a tuned number:
|
||||
core's step lease is fifteen minutes and its retry backoff is bounded well inside that, so expiry
|
||||
should never be the thing that ends a key's life. The cap is the guarantee's **one hole** — an
|
||||
evicted key's repeat *would* be applied a second time — so an eviction that drops a key still inside
|
||||
its TTL prints a console warning naming the count. If the promise is ever actually breached, an
|
||||
operator reads it here rather than discovering a doubled spawn in the world.
|
||||
|
||||
`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated= refused=)`.
|
||||
|
||||
#### 2.1.1 How the reply is captured
|
||||
|
||||
The handlers emit their answers through `BridgeLink.Emit` like everything else, so there is nothing
|
||||
to return. While a keyed handler runs, `Emit` offers each line to the store, and the store keeps
|
||||
**only the line the sidecar would correlate with this command** — the one whose `reqId`/`code`/`id`
|
||||
equals the command's.
|
||||
|
||||
That filter is the point. An `admin.audit` frame emitted alongside the reply is a fact about the
|
||||
world and must **not** be replayed to a retry; the reply is an answer to a caller and must be. The
|
||||
capture happens before `Emit`'s `_running` check, too, because a reply the link was too dead to
|
||||
deliver is exactly the one a retry will come back for.
|
||||
|
||||
A keyed command that produces no correlated reply at all is treated as a defect rather than as a
|
||||
state to model: it is logged by kind and key, and a definite `bridge.error` is stored so the retry
|
||||
terminates instead of timing out forever.
|
||||
|
||||
### 2.2 `champ.boss.killed`
|
||||
|
||||
A new kind, emitted from `EventSink.CreatureDeath` in `BridgeChamps`.
|
||||
|
||||
```json
|
||||
{"t":1788551315,"kind":"champ.boss.killed","category":"champion",
|
||||
"bossSerial":"0xD8D","boss":"Semidar","bossType":"Semidar",
|
||||
"map":"Felucca","x":1496,"y":1628,"z":-5,
|
||||
"serial":"0x400150E8","type":"Abyss","level":0,
|
||||
"region":"Britain",
|
||||
"killer":{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true},
|
||||
"damagers":[{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true,"damage":100240},
|
||||
{"serial":"0x24C","name":"Darrow","acct":"whitlocktech","webId":"14","player":true,"damage":120}]}
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `category` | `champion` or `sea`. There is no `mini`: a `MiniChamp` has no boss |
|
||||
| `bossSerial`, `boss`, `bossType` | The creature. `boss` prefers its display name and falls back to its type |
|
||||
| `serial`, `type`, `level` | **The ALTAR**, present only when the kill could be attributed to one. `serial` means the spawn here, matching `champ.update`, so a consumer joins the two without a rule about which of two serials means what |
|
||||
| `map`, `x`, `y`, `z`, `region` | Where it fell. `region` is the nearest **named** region and is absent in open countryside — see §6.1 |
|
||||
| `killer` | The standard actor object, and the last blow only |
|
||||
| `damagers` | Every player who damaged it, **highest first**, each an actor object plus `damage` |
|
||||
|
||||
**Detection is by TYPE, not by the sweep.** `BaseChampion` — which `BaseSeaChampion` derives from —
|
||||
so one check covers both families, and a boss that popped and died inside one sweep interval is
|
||||
still reported. The sweep's `bossSerial → spawnSerial` map supplies only the altar *context*; a kill
|
||||
it cannot attribute arrives without `serial`, `type` and `level` rather than not arriving.
|
||||
|
||||
**The damage table exists here and nowhere else.** ServUO discards a creature's damage entries with
|
||||
the creature, so the shard is the only party that ever sees them. Totals are summed per damager
|
||||
rather than trusted to be one entry each: ServUO folds repeat damage into an existing entry, but an
|
||||
entry that expired and was re-created leaves two, and a table listing the same player twice reads as
|
||||
two participants. Entries are reported **whether or not ServUO considers them expired** — expiry
|
||||
governs looting rights, and someone who fought the first two thirds of a champion fight and then
|
||||
died took part in it regardless of what they are owed from the corpse.
|
||||
|
||||
Capped at 20 entries so the frame stays one line on the wire.
|
||||
|
||||
---
|
||||
|
||||
## 3. The sidecar side
|
||||
|
||||
`PROTOCOL_VERSION: u32 = 5` → `6`, and **one behaviour**: `bridge.busy` maps to HTTP **425 Too
|
||||
Early** in all three responders.
|
||||
|
||||
Everything else is free. The key rides in the command body, which every write endpoint already
|
||||
passes through verbatim; `champ.boss.killed` lands in `events` and on the feed through the generic
|
||||
forward path with no arm of its own. There is **no store migration** — nothing gains a column.
|
||||
|
||||
That is the dumb-forwarder property doing its job again (v3 §3), and it is worth naming what it
|
||||
means here specifically: **the sidecar makes no idempotency promise of its own.** It does not dedupe,
|
||||
does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which
|
||||
is the only place it can be — the shard is where the world write happens.
|
||||
|
||||
11b adds six routes and no new machinery. `event_call` is `admin_call` without the required
|
||||
`actor`: an event verb's author is a **run**, which the body carries as `runId`, and demanding a
|
||||
human name for something no human is doing would have the runner inventing one.
|
||||
|
||||
| Route | Command | Note |
|
||||
|---|---|---|
|
||||
| `GET /lease` | `lease.list` | The whole catalog with current values. One read serves both `read()` and `inForce()` on the website's side |
|
||||
| `POST /lease` | `lease.apply` | |
|
||||
| `POST /lease/release` | `lease.release` | A drifted answer is a **200** — see §8.3 |
|
||||
| `POST /participation` | `participation.open` | |
|
||||
| `POST /participation/:runId/snapshot` | `participation.snapshot` | **A POST for a read**, because it carries the caller's `idempotencyKey` and can be refused as a repeat in flight. A read that can legitimately answer 425 is not a GET |
|
||||
| `POST /participation/:runId/close` | `participation.close` | |
|
||||
|
||||
`respond_event` is the fourth responder, and it exists for two mappings the generic one gets wrong:
|
||||
a drifted lease is a 200 rather than a reason-sniffed 400, and the event plane being **switched off**
|
||||
is a 403 rather than a 400 — `Bridge.EventsEnabled` is an operator's deliberate refusal, and telling
|
||||
the website it sent a bad request would send an administrator hunting a bug in a step that is
|
||||
written correctly.
|
||||
|
||||
### 3.1 Why 425 and not 409
|
||||
|
||||
409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a
|
||||
client: a version mismatch is a deployment fault nobody should retry, and a busy shard is a retry
|
||||
that will succeed on its own. Sharing a status would make the difference readable only by inspecting
|
||||
the body, which is how a retry loop ends up hiding a mismatched deployment.
|
||||
|
||||
425 is what that status is for — a server unwilling to risk processing a request that might be a
|
||||
replay — and it is unambiguous here because nothing else on this surface uses it.
|
||||
|
||||
A **replayed** reply, by contrast, is an ordinary **200**. The caller must be able to treat it
|
||||
exactly as it would have treated the answer it lost; `replayed: true` is for the log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visibility
|
||||
|
||||
One classification, made on the website in `module-uo`'s `shardVisibility.js`, never in the sidecar.
|
||||
|
||||
| Field / kind | Audience | Why |
|
||||
|---|---|---|
|
||||
| `champ.boss.killed` | **`anonymous`**, on the existing `champs` feature | A champion falling is announced in-world and is the content the public board is *for*. Mapping it is required, not optional: rule 2 fails an unmapped kind closed to admin-only |
|
||||
| `champ.boss.killed` → `damagers` | **`staff`**, configurable | Nested, so one rule covers the whole table |
|
||||
| `champ.boss.killed` → `killer` | **not listed** | One actor, whose blow everyone present saw, and the same disclosure `mob.killed` has published on the public activity feed since before this framework existed |
|
||||
| `damagers[].acct` / `.webId` | **admin, locked** | Rule 1, automatically, by suffix — inside the array, exactly as it already works inside a guild roster |
|
||||
|
||||
**The split is the whole point.** A shard announces that its champion fell without publishing a
|
||||
ranked roll of who was strong enough to fell it. The kill is an event in the world; the damage table
|
||||
is a performance record of named players that nobody consented to publish. A shard that wants a
|
||||
public "who slew the champion" board lowers **one** field rule.
|
||||
|
||||
**11b's two frames are deliberately unmapped.** `lease.applied` and `lease.expired` are operational
|
||||
records of the WEBSITE changing this shard's configuration — which key, from what to what, on whose
|
||||
run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an
|
||||
unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs;
|
||||
mapping them would mean choosing a feature an operator could then widen, and there is no rung below
|
||||
admin these belong on. Same reasoning as `account.login.result` in v5.
|
||||
|
||||
The participation ledger emits no stream frame at all. A tally is read on request, not broadcast:
|
||||
a live feed of who is standing where would be a location tracker, and the ledger's whole justification
|
||||
is that it answers one bounded question about one run.
|
||||
|
||||
**The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount`
|
||||
and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into
|
||||
mail an operator may address to every subscriber, so a name reaching the trigger's data would undo
|
||||
the field rule one layer up — a distinct hole from the SSE path the rule guards, reachable without
|
||||
touching the visibility config at all.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-repo obligations
|
||||
|
||||
| Repo | Change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** |
|
||||
| `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders |
|
||||
| `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` |
|
||||
| `website` | **11b only.** `core.lease` gains a `reconcile()` and `registerEventLeases` gains an optional `inForce()` — see §8.4 |
|
||||
| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` · `MODULE_API.md` (11b) |
|
||||
| `installer` | **nothing.** See below |
|
||||
| `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
|
||||
|
||||
**`installer` has no work, and `EVENTS_PLAN.md` Phase 11 was wrong to list it.** The plan put it in
|
||||
the phase *"because of the pairing"*, but `PLAN.md` §7.4 made that unnecessary on purpose:
|
||||
**no protocol version is hardcoded anywhere in the installer.** `bundle.rs` reads the number out of
|
||||
the bundle document and `bundle.yml`'s Gate 1 reads both halves out of the released artefacts and
|
||||
refuses a mismatch. The check is version-agnostic, so it needs no change for 6 exactly as it needed
|
||||
none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is
|
||||
released or bundled until the events cutover in any case.
|
||||
|
||||
**`EVENTS_PLAN.md` was wrong about the repo count in the other direction too.** Phase 11 is four
|
||||
repos for 11a and **five** for 11b: a lease's ledger row had no reconcile path anywhere, because the
|
||||
step that made it names `core.lease`, and that is core's own action rather than a module's. There is
|
||||
nowhere on that path a module could hang an answer, so core had to grow one. §8.4.
|
||||
|
||||
**The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and
|
||||
`module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree*
|
||||
rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
Unit tests: 571 in `module-uo/server` (571 pass / 0 fail, 9 new), 43 in the sidecar (4 new), 42 in
|
||||
`module-uo/client`; the C# compiles against the real ServUO 57.4 reference assemblies. `cargo fmt`,
|
||||
`cargo clippy -D warnings` and `check:imports` clean.
|
||||
|
||||
Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles, 209k
|
||||
items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol6Probe.cs`.
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| a repeat is not re-executed | Three `POST /admin/broadcast`, two under key `K1` and one under `K2`. The shard's own audit trail holds **two** `admin.audit` rows, not three |
|
||||
| a replay is the ORIGINAL reply | Attempt 2 answered `{"kind":"admin.ok","replayed":true,"reqId":"r-2","t":1788550182074}` — the first attempt's `t`, so it is stored rather than re-run |
|
||||
| a replay is stamped with the REPEAT's id | The same reply carries `reqId: r-2`, not the `r-1` the first attempt used. Without this the retry hangs until the reply timeout |
|
||||
| the `id`-correlated plane works too | Two `POST /towncrier` under one key → `{"kind":"towncrier.ok","id":"evt-CRIERKEY","replayed":true}` on the second, correlated on `id` rather than `reqId` |
|
||||
| a different key still executes | Attempt 3 answered a fresh `r-3` with a new `t`, and produced the second audit row |
|
||||
| the shard says what it did | `[Bridge] idempotency: replaying the original reply for key K1-… (admin.broadcast)` |
|
||||
| `champ.boss.killed` fires for a real champion | A real `ChampionSpawn` driven through its own `SpawnChampion()`, damaged from two seeded players and killed: `boss: Semidar`, `category: champion` |
|
||||
| the altar is attributed | `serial: 0x400150E8`, `type: Abyss`, `level: 0` — from the sweep's map, so the kill names the altar and not only the creature |
|
||||
| the damage table ranks and SUMS | `[("tester", 100240), ("Darrow", 120)]` — descending, and `tester`'s 240 registered damage plus the 100000 killing blow folded into one entry rather than two |
|
||||
| the region resolves | `region: "Britain"` — see §6.1, this is the one that took four rig cycles |
|
||||
| the website's half agrees, on the real bytes | The captured frame fed through `module-uo`: trigger `uo.champ.boss_killed` with `location: "Felucca 1496, 1628 (Britain)"`, `damagerCount: 2`; projected `damagers` **absent** for `anonymous` and `player`, present for `staff`, and `acct` stripped from every actor at every rung below admin |
|
||||
| the sidecar needed no change beyond the constant and one status | `GET /health` → `"protocol":6`; the key and the new kind both arrived through the generic paths |
|
||||
|
||||
**One thing the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat can
|
||||
never arrive while the original is still running, because the Core thread processes one inbound line
|
||||
at a time — so the state is unreachable on a live shard until a handler defers. It is implemented,
|
||||
`BridgeIdempotency.Hold`/`Complete` are the door a deferring handler uses, and the sidecar's 425
|
||||
mapping is unit-tested on all three responders. **Phase 11b's leases are the first thing that can
|
||||
actually produce it**, and proving it belongs in that walk rather than being claimed here.
|
||||
|
||||
### 6.1 The rig trap: the innermost region has no name
|
||||
|
||||
Recorded because it is the sort of defect that ships, and because the naive check *confirms* it.
|
||||
|
||||
The first emitter read `boss.Region` and a champion killed in the middle of Britain produced a frame
|
||||
with **no region at all**. Three things were true at once:
|
||||
|
||||
- An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its spawn area, constructed with
|
||||
a **null name** and with the town region as its `Parent`. So the most specific region containing a
|
||||
champion boss is, by construction, the one region on the map guaranteed to have no name.
|
||||
- `Mobile.Region` never returns null: with no cached region it falls back to the map's **unnamed
|
||||
default region**. So the failure looks identical to "outdoors, nowhere in particular".
|
||||
- `ChampionSpawn.SpawnChampion` places its boss at `Z - 15`, which was a plausible-looking second
|
||||
explanation and a red herring. It cost a rig cycle.
|
||||
|
||||
And the reason it survived a first look: **region registration is deferred**, so a lookup taken
|
||||
immediately after the altar is placed still answers `"Britain"` and one taken at the kill twenty
|
||||
seconds later does not. The probe printed `region "Britain"` at spawn on every run, including the
|
||||
ones whose frame carried nothing. A read at spawn time would have confirmed the bug into the design.
|
||||
|
||||
The fix is general rather than a special case for champions: walk outward to the nearest **named**
|
||||
ancestor. A house region, a dungeon sub-region and a guarded-zone overlay are all anonymous children
|
||||
of somewhere a player would name. The map's own default region terminates the walk with its
|
||||
parentless empty name, so open countryside answers null — and the field is then **omitted**, because
|
||||
a consumer reading `region: ""` cannot tell "nowhere in particular" from "the shard would not say".
|
||||
|
||||
### 6.2 Two smaller ones
|
||||
|
||||
- **`deploy.ps1` overwrites `Bridge.cfg`,** so every deploy strips the scaffolding flags —
|
||||
`Protocol6ProbeOnStart` and, here, `AdminWriteEnabled` as well. The probe then silently does
|
||||
nothing on the next boot. The scaffolding README warns about this and it still cost a cycle.
|
||||
- **A running ServUO holds `Scripts.dll`,** so an offline `msbuild` fails with `MSB3027` rather than
|
||||
a compile error. Stop the shard, build, restart — and read the boot log's `0 Error(s)` to confirm
|
||||
the new code is live rather than trusting a clean boot, which is what
|
||||
`servuo-plugin-build-and-run` already records.
|
||||
|
||||
---
|
||||
|
||||
## 7. One version, two halves
|
||||
|
||||
Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping
|
||||
to 7 — 6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule
|
||||
is that a protocol owes a bump once it has shipped and is amended in place before that.
|
||||
|
||||
Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side
|
||||
of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle
|
||||
CI never sees either half until the cutover, by which time 6 means one thing. It would not be
|
||||
tolerable on `main`, and this paragraph exists so nobody discovers that the hard way.
|
||||
|
||||
Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 8. The lease plane (11b)
|
||||
|
||||
> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is
|
||||
> held.
|
||||
>
|
||||
> — `EVENTS.md`, *Leases: the primitive underneath everything*
|
||||
|
||||
Three commands, one new configuration gate, and two properties that are the whole reason the
|
||||
framing is worth having.
|
||||
|
||||
### 8.1 `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled`
|
||||
|
||||
**Its own switch, default off** (org lead, 2026-09-04). Enabling the admin plane is an operator
|
||||
consenting to staff moderation driven from the website — a human pressing kick or ban on a screen
|
||||
they are looking at. A lease and a participation ledger are the website changing and watching the
|
||||
world on a **schedule**, unattended, at four in the morning. Those are different consents and one
|
||||
switch cannot honestly express both; an operator who wanted the first and got the second would be
|
||||
right to be angry.
|
||||
|
||||
### 8.2 The commands
|
||||
|
||||
```json
|
||||
{"kind":"lease.apply","reqId":"r-7","key":"PlayerCaps.SkillCap","value":"1200",
|
||||
"holdMs":600000,"untilMs":1788567000000,"runId":"77","idempotencyKey":"…"}
|
||||
```
|
||||
|
||||
| Command | Answers | |
|
||||
|---|---|---|
|
||||
| `lease.list` | `lease.list.ok` | Every allowlisted key: `current`, `default`, `min`/`max`, and where held, `baseline` / `applied` / `untilMs` / `runId` / `expired` |
|
||||
| `lease.apply` | `lease.ok` | `baseline`, `applied`, `untilMs` |
|
||||
| `lease.release` | `lease.ok` or `lease.drifted` | Compare-and-set |
|
||||
|
||||
Three shapes are worth stating because the obvious alternative is subtly wrong in each.
|
||||
|
||||
**`holdMs` is authoritative and `untilMs` is for display.** An absolute deadline computed on the
|
||||
website and honoured on the shard is a deadline measured against **two clocks**, and a shard running
|
||||
ten minutes fast would restore a ten-minute lease the instant it took it. A duration is immune. The
|
||||
absolute time still crosses, because a console that can say when the hold ends in terms the
|
||||
operator's own clock agrees with is worth one field.
|
||||
|
||||
**Values cross as TEXT, whatever the lease's declared type**, and comparison is done on the parsed
|
||||
values. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a parser and two
|
||||
different strings to a compare-and-set, and a drift check that compared formatted numbers would
|
||||
report drift on a value nobody had touched — refusing to restore, leaving the world changed, and
|
||||
blaming an innocent operator.
|
||||
|
||||
**A lease held longer than the shard's ceiling is REFUSED, never clamped.** A clamp would quietly
|
||||
give the website a shorter lease than it believes it has, and the website is the half that schedules
|
||||
the restore; the two would then disagree about when the world comes back. `Bridge.LeaseMaxDurationSec`
|
||||
is the shard's independent bound rather than a mirror of core's — it exists for the case where the
|
||||
website is wrong, and being loud about it is the entire value.
|
||||
|
||||
### 8.3 The two mechanisms, and one thing a stock shard cannot do
|
||||
|
||||
**The deadline lives on the shard.** A lease arms a timer, and when it passes the shard restores
|
||||
baseline **whether or not the website is ever heard from again**. Core drives the normal restore;
|
||||
this is the backstop. It inverts the naive design, where restoration depends on core dispatching a
|
||||
cleanup step and therefore fails *open* if core dies mid-event. A lease fails *safe*, and the worst
|
||||
case is a world back at baseline early rather than one stuck changed indefinitely. The shard emits
|
||||
`lease.expired` so the website learns what happened without being asked.
|
||||
|
||||
**Restore is compare-and-set, never a blind write.** If the current value is not what the event
|
||||
applied, somebody moved it deliberately: answer `lease.drifted` with the current value, leave the
|
||||
world alone, and let an operator decide. A **200**, not a 409 — the shard did exactly what it was
|
||||
asked, and 409 is the version gate's with the opposite disposition. Blindly restoring would silently
|
||||
revert a staff member's change, which is the one failure that would make operators distrust the
|
||||
whole feature.
|
||||
|
||||
> **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`,
|
||||
> for `Compiler.Dynamic`. There is no in-game command, no gump and no console verb that writes a
|
||||
> config key.
|
||||
|
||||
So on a stock shard a GM **cannot drift a configuration lease even deliberately**. The mechanism is
|
||||
still correct and still required — Phase 12's object-property leases are trivially driftable, and a
|
||||
shard with custom scripts may well write config at runtime — but proving it needs the `configset`
|
||||
verb in `tools/scaffolding/BridgeRigDriver.cs`, which exists for exactly that reason.
|
||||
|
||||
**A lease is memory-only, and that is a decision.** `Config.Set` mutates the in-memory entry table;
|
||||
`Config.Load()` is guarded by `_Initialized` and so runs once at boot, which is what makes a Set
|
||||
survive every later Get. **Nothing ever calls `Config.Save()`**, so a shard restart is a *free*
|
||||
restore — the strongest fail-safe available, at no cost. It is also why `lease.list` reports an empty
|
||||
hand after a restart, which is precisely what lets the website's reconcile notice the lease is gone.
|
||||
|
||||
A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's original
|
||||
default marker too, because the entry compares against the value it was loaded with. Restoring a key
|
||||
that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
|
||||
|
||||
### 8.4 The reconcile hole, and `inForce()`
|
||||
|
||||
**A lease's ledger row had no reconcile path at all, and nothing failed to say so.** `cleanup.js`
|
||||
resolves a resource to the action of the step that made it, and for a lease that action is
|
||||
`core.lease` — a **core** action, on a path a module cannot register anything on. So every `override`
|
||||
row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped stayed
|
||||
in the ledger as live until teardown went looking for a baseline nobody was holding.
|
||||
|
||||
11b closes it in two pieces, both in `website`:
|
||||
|
||||
- `core.lease` gains a `reconcile()`.
|
||||
- `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any
|
||||
record of this hold?"*
|
||||
|
||||
It is deliberately not `read()` plus a comparison. A value that differs from what the run applied is
|
||||
**drift**, which teardown must deliver through `restore()` so the row lands `drifted` with the
|
||||
current value beside it; a reconcile that inferred absence from a changed value would orphan the row
|
||||
first and destroy that signal — telling the operator the lease vanished rather than that somebody
|
||||
moved it. Only an explicit `{ ok: true, held: false }` takes a row out; a throw, a timeout, an
|
||||
unrecognised shape and a lease with no `inForce()` all leave it alone.
|
||||
|
||||
`MODULE_API_VERSION` stays **1.10.0**, amended in place, by the same rule §7 states for the protocol.
|
||||
|
||||
### 8.5 The catalog is short, and shorter than `EVENTS.md` expected
|
||||
|
||||
§D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type
|
||||
initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes
|
||||
effect at once. Measured on 57.4 the split is not near even: of the **158** non-Bridge call sites in
|
||||
`Scripts/`, roughly **eight** are live reads.
|
||||
|
||||
11b ships **one** key: `PlayerCaps.SkillCap`, read live inside `CharacterCreation.cs`'s per-character
|
||||
path and divided by ten to give the per-skill cap. It is both live and observable, which is what
|
||||
"proven" has to mean here — the failure the allowlist exists to prevent is a key that applies
|
||||
cleanly and changes nothing at all. Phase 12 adds the rest, with the boot-time self-check that drops
|
||||
a key from the advertised catalog if it does not take.
|
||||
|
||||
---
|
||||
|
||||
## 9. The participation ledger (11b)
|
||||
|
||||
§G rates participation attribution as the largest remaining piece of new UO work, and says why
|
||||
nothing composed out of the existing streams stands in for it: `region.enter` plus `mob.killed` is
|
||||
loosely composable and **not trustworthy enough to publish results on**. Nothing scopes a kill or an
|
||||
arrival to a run, nothing separates a passer-by from an attendee, and nothing survives a relog.
|
||||
|
||||
| Command | Answers | |
|
||||
|---|---|---|
|
||||
| `participation.open` | `participation.ok` | `{ runId, map, x, y, radius, holdMs }` |
|
||||
| `participation.snapshot` | `participation.snapshot.ok` | The tally, resolved to names and accounts |
|
||||
| `participation.close` | `participation.ok` | Stops counting; the tally stays readable through the grace window |
|
||||
|
||||
**The area is a map, a point and a radius** (org lead, 2026-09-04). Not a region name: §6.1 above
|
||||
established that the most specific region containing an event is routinely **anonymous**, so a
|
||||
region-named area would be undeclarable for exactly the venues events use. Not a rectangle either —
|
||||
an author picks the spot the event happens at, not two opposite corners of it.
|
||||
|
||||
**Members are keyed by character serial**, matching `module-uo`'s existing Teams `memberKey`, so one
|
||||
module speaks one member vocabulary and a participant joins to a roster without a translation table.
|
||||
A player who attends on two characters is two members, which is the answer Teams already gives.
|
||||
|
||||
**The shard computes the score and core stores an opaque number it never interprets.** *"A minute
|
||||
present plus five a kill"* is a sentence about Ultima Online, and the sentence has to live on the
|
||||
Ultima Online side of the seam. The components ride along in the frame anyway, because a results
|
||||
table that can say "forty minutes and three kills" beside a number is one an operator can defend
|
||||
when a player argues with it.
|
||||
|
||||
Two shapes that look like details and are not:
|
||||
|
||||
- **Presence accrues in SECONDS, not sample counts.** A count would have to be multiplied by the
|
||||
sweep interval to mean anything, and the interval is a config key an operator may change halfway
|
||||
through a five-hour run — silently rewriting the first half of the tally. The kill weight is frozen
|
||||
per run at open for the same reason.
|
||||
- **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: the player who held it for four minutes and died to it took
|
||||
part more than the one who landed the blow that finished it. The area test is applied to the
|
||||
**damager**, so someone shooting in from outside is not attending and someone who has since walked
|
||||
away accrues nothing more. Entries are summed per damager for the reason `champ.boss.killed`'s
|
||||
table is (§2.2): an expired-and-recreated entry leaves two.
|
||||
|
||||
### 9.1 The Bridge's first persisted state
|
||||
|
||||
Nothing in this bridge has ever persisted anything. A ledger has to: a run spans hours and a restart
|
||||
mid-event is an ordinary Tuesday, and an in-memory tally would silently regress every attendee's
|
||||
score to whatever they earned after the restart. The only ways to paper over that from the other side
|
||||
are a high-water rule in core — which must stay game-agnostic and cannot have one — or a per-run
|
||||
offset in the module, which is the same bug with more moving parts.
|
||||
|
||||
`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the
|
||||
world save, rather than a persistence **item**: no world object, no serial, nothing for a GM to find
|
||||
and delete by accident, and a wipe of custom items leaves the ledger intact. The save and load hooks
|
||||
are attached **unconditionally**, before the enabled gate is consulted — an operator who switches the
|
||||
plane off for an afternoon must not come back to a truncated file where a run's tally used to be.
|
||||
|
||||
Bounds, all `Bridge.*` keys: eight runs counted at once, 2000 members per run, a 300-tile ceiling on
|
||||
the area, and a 24-hour grace window after a run closes. A member the cap turns away is **counted**
|
||||
and the count rides on every snapshot: a truncated tally that says it is truncated is usable, and one
|
||||
that does not is a leaderboard with people missing from it for no stated reason.
|
||||
|
||||
### 9.2 The first handler that defers
|
||||
|
||||
`participation.snapshot` resolves every member serial to a mobile and an account, so a well-attended
|
||||
run is hundreds of world lookups in one inbound call — exactly the work the Core thread must not be
|
||||
handed in one piece. Above `Bridge.ParticipationSnapshotChunk` members it walks in chunks across
|
||||
ticks, using `BridgeIdempotency.Hold` / `Complete`.
|
||||
|
||||
That makes it the first handler in the bridge to complete **after** its inbound call returns, and
|
||||
therefore the first that can genuinely answer `bridge.busy`. 11a built that door and had nothing to
|
||||
walk through it.
|
||||
|
||||
---
|
||||
|
||||
## 10. Verification (11b)
|
||||
|
||||
Unit tests: **588** in `module-uo/server` (17 new), **42** in `module-uo/client`, **2003** in
|
||||
`website/server` (5 new), **47** in the sidecar (4 new). `cargo fmt`, `cargo clippy -D warnings` and
|
||||
`check:imports` clean; the C# compiles against the real ServUO 57.4 reference assemblies with the
|
||||
shard stopped.
|
||||
|
||||
The rig: a real ServUO with a seeded world, the **release** Rust sidecar, and
|
||||
`tools/scaffolding/BridgeRigDriver.cs` (`configset` / `configread` / `partprobe`) plus
|
||||
`BridgeParticipationProbe.cs`.
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| a lease applies, and the change is visible through the shard's own reader | `lease.apply` → `{"baseline":"1000","applied":"1200"}`; an independent `Config.Get` from a different class, long after every type initialiser, read `1200` |
|
||||
| **the shard restores baseline with nobody asking** | A 20-second lease, and then silence. `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, and `lease.expired` on the feed with `restored: true` |
|
||||
| an expired lease still yields a verdict to teardown | `lease.list` reported `held: true, expired: true, restored: true`; the later `lease.release` answered `alreadyRestored: true` rather than an error |
|
||||
| **a mid-lease GM edit produces `drifted`, and the world is LEFT ALONE** | `configset PlayerCaps.SkillCap 1350` under a live lease → `lease.release` answered `{"kind":"lease.drifted","current":"1350"}` at **200**, and a read afterwards still showed `1350` |
|
||||
| a restart reverts a config lease, and the catalog says so | After a restart: `held: false`, `current: "1000"` — which is what makes `inForce()` correct |
|
||||
| kill credit is per damager, inside the area | Two seeded players damaging one creature at the venue: both credited `kills: 1`, `score: 5.0` |
|
||||
| **the tally survives a shard restart mid-run** | `save`, `shutdown`, boot: `[Bridge] participation: 1 run(s) restored from the world save`, and the snapshot returned both members with identical `firstMs` |
|
||||
| **the first live `bridge.busy`** | Two concurrent `POST /participation/:runId/snapshot` under one key, chunk size 1: the first answered 200 with the tally, the second **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` |
|
||||
| a deferred key replays like any other | A third request under the same key answered `replayed: true` under its own `reqId` and the **first attempt's** `t` |
|
||||
|
||||
### 10.1 The defect the deferral found, in 11a's own code
|
||||
|
||||
`bridge.busy` came back **200**, not 425, the first time anything produced it.
|
||||
|
||||
`BridgeIdempotency.Busy` built its frame with `BridgeJson.Begin("bridge.busy")` — which writes
|
||||
`"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)` naming the command
|
||||
that was in flight. **The object carried two `kind` fields, and every JSON parser worth the name
|
||||
takes the last.** The sidecar matched on `bridge.busy` to decide the 425, read
|
||||
`participation.snapshot` instead, and answered an ordinary 200 with a body saying nothing had
|
||||
happened — the single worst of the three possible answers, since a retry loop would treat it as
|
||||
success.
|
||||
|
||||
It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never
|
||||
arrive mid-flight, so the arm was unreachable on a live shard, and the sidecar test that covers the
|
||||
mapping was — correctly — feeding it a frame built by hand. The first deferring handler produced it
|
||||
on its first collision. Renamed to `busyKind`.
|
||||
|
||||
This is the argument for the phase's ordering, stated as a fact rather than a hope: 11a said
|
||||
*"11b's leases are the first thing that can actually produce it, and proving it belongs in that
|
||||
walk"*, and the walk found a real bug in shipped code.
|
||||
|
||||
### 10.2 What the rig could not drive: presence
|
||||
|
||||
The participation ledger counts two things and only one of them is reachable headlessly.
|
||||
|
||||
**Presence needs a connected client.** The sweep credits online players — `NetState != null` — which
|
||||
is the correct test and not one a probe should loosen: a character parked in Britain and logged out
|
||||
for eight hours did not attend anything, and a ledger that said otherwise would put people at the top
|
||||
of a leaderboard for being AFK. There is no way to produce a NetState short of writing a client, and
|
||||
ClassicUO cannot be driven from this machine.
|
||||
|
||||
**Kill credit needs none**, so the whole of the credit path — the damager filter, the per-damager
|
||||
fold, the area test on the damager, the member cap — ran exactly as it would in a fight, and the
|
||||
accrual, persistence, snapshot, chunking and replay paths were driven by it. The one line the walk
|
||||
did not exercise is `member.Seconds += seconds`, and it is named here rather than left to be assumed.
|
||||
|
||||
### 10.3 Two rig traps
|
||||
|
||||
- **`Core.Kill` does not save the world.** The rig driver's `shutdown` verb is a clean shutdown, which
|
||||
is the only kind that *emits* — and it emits `server.shutdown` without writing a save. The first
|
||||
restart test therefore reloaded an **empty** `Participation.bin` and looked exactly like a
|
||||
persistence bug. `save` then `shutdown` is the sequence; the file's length is the check.
|
||||
- **A probe that means to produce two damagers must not kill with the first blow.** An opening 40
|
||||
damage on a Mongbat (around thirty hit points) killed it where it stood, so the second damager never
|
||||
landed a hit, `DamageEntries` held one name, and the ledger correctly credited one player. It read
|
||||
as a plugin crediting only the killer. Scaled to the creature's `HitsMax`.
|
||||
571
link/v7.md
Normal file
571
link/v7.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# 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.
|
||||
@@ -1117,6 +1117,190 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
|
||||
|
||||
---
|
||||
|
||||
### The six event tables — the engine's, game-agnostic (events phase 1)
|
||||
|
||||
Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event
|
||||
table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are
|
||||
spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty
|
||||
tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and
|
||||
`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in
|
||||
Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id`
|
||||
that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score
|
||||
and a `rank_at` written only when results are published. Core writes it and sources none of it: a
|
||||
`member_key` → account mapping is one game's, and a module reports both halves on its action's
|
||||
success envelope.
|
||||
|
||||
Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at`
|
||||
(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news
|
||||
pipeline has already announced without either job standing on the other's toes. Everything that
|
||||
means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means
|
||||
the one with a NULL `run_id`.
|
||||
|
||||
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
|
||||
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
|
||||
interprets.
|
||||
|
||||
#### event_series — the arc
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
|
||||
| description | TEXT NULL | |
|
||||
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
|
||||
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
|
||||
arrives with the calendar.
|
||||
|
||||
#### event_definitions — the thing that is listed, scheduled and audited
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| title | VARCHAR(200) NOT NULL | |
|
||||
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
|
||||
| summary | VARCHAR(500) NULL | |
|
||||
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
|
||||
| image_url | VARCHAR(500) NULL | |
|
||||
| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case |
|
||||
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
|
||||
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
|
||||
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
|
||||
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
|
||||
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
|
||||
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
|
||||
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
|
||||
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
|
||||
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
|
||||
`INDEX(series_id, series_order)` — the arc.
|
||||
|
||||
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
|
||||
"Editing a draft is free; no version exists yet" means the working copy has to live somewhere, and it
|
||||
cannot be an `event_versions` row: that table is immutable and a run pins one, so a mutable
|
||||
unpublished row in it would be exactly what versioning exists to prevent. Publishing copies this
|
||||
column into a version and leaves it standing as the next draft.
|
||||
|
||||
#### event_versions — the immutable snapshot a run pins
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
||||
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
|
||||
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
|
||||
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
|
||||
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
|
||||
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
|
||||
makes a run reproducible and an audit answerable after the definition has moved on.
|
||||
|
||||
#### event_runs — one occurrence, in one scope
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
||||
| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for |
|
||||
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
|
||||
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
|
||||
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
|
||||
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
|
||||
| current_phase | VARCHAR(64) NULL | |
|
||||
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
|
||||
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
|
||||
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
|
||||
| params | JSON NULL | |
|
||||
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
|
||||
| started_at / ended_at | DATETIME NULL | |
|
||||
| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one |
|
||||
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
|
||||
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
| last_error | VARCHAR(500) NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
|
||||
occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what
|
||||
stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers
|
||||
without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one
|
||||
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
|
||||
interpret.
|
||||
|
||||
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
|
||||
· `INDEX(concurrency_key, status)` the overlap check.
|
||||
|
||||
#### event_run_steps — the work queue
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
||||
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
|
||||
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
|
||||
| params | JSON NULL | validated against the action's declared params at save |
|
||||
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
|
||||
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
|
||||
| due_at | DATETIME NULL | |
|
||||
| attempts | INT NOT NULL DEFAULT 0 | |
|
||||
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
|
||||
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
|
||||
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
|
||||
| last_error | VARCHAR(500) NULL | |
|
||||
| started_at / finished_at | DATETIME NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
|
||||
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
|
||||
`INDEX(run_id, phase, seq)` the run console.
|
||||
|
||||
#### event_run_log — "why didn't phase 3 start?" must be a query
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
||||
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
|
||||
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
|
||||
| phase | VARCHAR(64) NULL | |
|
||||
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
|
||||
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
|
||||
|
||||
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
|
||||
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
|
||||
goes here. They are different questions with different readers and different retention. The writer
|
||||
**never throws** — a failure to record why something went wrong must not become a second failure on
|
||||
top of the first.
|
||||
|
||||
**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.**
|
||||
`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows
|
||||
are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the
|
||||
beginning.
|
||||
|
||||
#### The action registry — declared, never stored
|
||||
|
||||
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
|
||||
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
|
||||
would outlive the module that can perform it. `modules/registries.js` gained
|
||||
`registerEventActions` in this phase, with core as its first registrant —
|
||||
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
|
||||
exercised on every boot long before a module uses it.
|
||||
|
||||
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
|
||||
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
|
||||
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
|
||||
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
|
||||
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
|
||||
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
|
||||
naming one by id.
|
||||
|
||||
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
|
||||
builds its own `api` facade for a module and has no method that delegates to it, so no module can
|
||||
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
|
||||
phase's.
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
|
||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||||
@@ -1512,8 +1696,34 @@ file a route sits in — that is the property the route manifest freezes.
|
||||
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
||||
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
||||
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
||||
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
|
||||
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
|
||||
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
|
||||
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
|
||||
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
|
||||
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
|
||||
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
|
||||
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
|
||||
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
|
||||
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
|
||||
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
|
||||
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
||||
|
||||
> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it.
|
||||
> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5),
|
||||
> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup`
|
||||
> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here.
|
||||
> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them
|
||||
> with the reasoning for its gate; re-listing them here would be a second copy of a contract that
|
||||
> file owns, and the copy that drifts is always the second one.
|
||||
>
|
||||
> The Phase 14a reads are the only ones outside this tier: `GET /public/events`,
|
||||
> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and
|
||||
> `GET /player/events/history` on the self-service one. What makes something visible there is
|
||||
> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than
|
||||
> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404,
|
||||
> indistinguishable from a slug that never existed.
|
||||
|
||||
Every admin write logs to `activity_log`.
|
||||
|
||||
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
|
||||
|
||||
@@ -1676,7 +1676,7 @@ deferred to this phase on purpose:
|
||||
| | Question | Decision |
|
||||
|---|---|---|
|
||||
| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle |
|
||||
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet |
|
||||
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today. **The evaluator was assigned on 2026-09-01** ([EVENTS.md §N3](EVENTS.md#n--decisions)): the Event System runner becomes it, built in that workstream P2 |
|
||||
| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed |
|
||||
|
||||
**One namespace was the more invasive of the two options and it is worth stating what it bought and
|
||||
@@ -4078,6 +4078,14 @@ day it ships.
|
||||
caller's — so `kind` means something from the moment it is declarable rather than from the moment
|
||||
it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and
|
||||
the first thing anyone will ask for after the IDOC mail works.
|
||||
|
||||
> **The evaluator now has an owner, decided 2026-09-01** ([`EVENTS.md`](EVENTS.md#n--decisions)
|
||||
> §N3). The **Event System runner** becomes the `kind: 'scheduled'` evaluator rather than a ninth
|
||||
> poller being written for it — the runner already materialises due occurrences on a tick, and a
|
||||
> second timer doing the same shape of work is how a codebase acquires two schedulers that
|
||||
> disagree. It is built in that workstream's P2. Nothing in this phase's contract changes: a
|
||||
> `scheduled` declaration means today exactly what it meant on 2026-08-29, and `ctx.events.emit`
|
||||
> still refuses to fire one.
|
||||
7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email
|
||||
marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be
|
||||
built if they are powered by game data — say team X members or governors or whatever — thru the uo
|
||||
@@ -4325,7 +4333,7 @@ worth confirming in Phase 4 rather than discovering in Phase 11.
|
||||
| **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) |
|
||||
| **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed |
|
||||
| **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap |
|
||||
| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled |
|
||||
| **Come back for X** — a scheduled event is starting | `event.run.started`, emitted by the event runner | ✅ **built — `EVENTS_PLAN.md` Phase 10**. It is a trigger AND a stream, which is what gives it push: the push channel is stream-keyed, so the six other `event.` triggers are email and in-app only | email, push, in-app | on the run |
|
||||
| **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed |
|
||||
| **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged |
|
||||
|
||||
|
||||
1339
website/EVENTS.md
1339
website/EVENTS.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,13 +26,223 @@ here extends the contract first, in this file, before the module is written agai
|
||||
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
||||
|
||||
```js
|
||||
const MODULE_API_VERSION = '1.9.0'
|
||||
const MODULE_API_VERSION = '1.10.0'
|
||||
```
|
||||
|
||||
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
||||
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
||||
module chunk evaluates, which is earlier than any network round trip could answer.
|
||||
|
||||
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
|
||||
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
|
||||
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
|
||||
Four additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
|
||||
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
|
||||
rule, because core with none of this installed is still an event engine that can announce, wait, cue
|
||||
a human and publish results.
|
||||
|
||||
> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a
|
||||
> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is
|
||||
> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module
|
||||
> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as
|
||||
> one version they read once, which was the argument for putting the lease declaration here in the
|
||||
> first place.
|
||||
>
|
||||
> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success
|
||||
> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0,
|
||||
> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option
|
||||
> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is
|
||||
> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional:
|
||||
> a module that never passes it is emitting exactly what it emitted before.
|
||||
|
||||
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
|
||||
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
|
||||
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam a module
|
||||
now reaches is one that has been exercised on every boot for six phases, rather than one whose first
|
||||
registrant is a stranger. That is the same argument `registerCore()` has made since the module
|
||||
system's Phase 3, and 1.10.0 is when it pays.
|
||||
|
||||
```js
|
||||
api.registerEventBudgets([
|
||||
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, // a DIMENSION core can bound
|
||||
])
|
||||
|
||||
api.registerEventActions([{
|
||||
id: 'uo.creature.spawn', // <moduleId>.-prefixed; its OWN id space
|
||||
label: 'Spawn creatures',
|
||||
risk: 'change', // closed: notify | inspect | change | irreversible
|
||||
reversible: 'ledger', // closed: none | self | ledger | override
|
||||
version: 1,
|
||||
budgetMs: 10000,
|
||||
cost: (p) => ({ 'uo.creatures': p.count }), // what ONE invocation consumes
|
||||
params: [
|
||||
{ name: 'creature', type: 'string', required: true,
|
||||
example: 'Orc', source: 'uo.options.creatures' }, // a `source` makes it a dropdown
|
||||
{ name: 'count', type: 'int', required: true, example: 12 },
|
||||
],
|
||||
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
|
||||
if (verify) return { ok: true } // dry run: validate, change NOTHING
|
||||
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
|
||||
},
|
||||
// Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown,
|
||||
// over the rows this action's `resources` produced — a LIST, so twelve creatures
|
||||
// are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names
|
||||
// the ones that did not come back.
|
||||
async revert({ runId, resources, idempotencyKey }) { return { ok: true } },
|
||||
// OPTIONAL, and only on an action that ledgers. "Which of these does the game
|
||||
// still have?" — asked after something outside core restarted.
|
||||
async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } },
|
||||
}])
|
||||
|
||||
// The module says WHEN, because core cannot: core has no concept of the game
|
||||
// being up. module-uo already watches `bootId` to tell a shard restart from a
|
||||
// sidecar reconnect, and that is the moment a ledger of live spawns has become a
|
||||
// claim about a world that no longer exists.
|
||||
ctx.events.reconcile()
|
||||
|
||||
api.registerEventOptionSources([{
|
||||
id: 'uo.options.creatures', label: 'Creatures',
|
||||
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
|
||||
}, {
|
||||
// A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and
|
||||
// requires it of none, so a resolver that ignores it is unchanged; `searchable`
|
||||
// is what tells the authoring form to render a typeahead rather than a select.
|
||||
id: 'uo.options.spawners', label: 'Spawners', searchable: true,
|
||||
async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) },
|
||||
}])
|
||||
|
||||
// A value a run may borrow. The module ships the three callables; the VERB an
|
||||
// author puts in a step is core's `core.lease`, so the duration bound and the
|
||||
// two-events-one-target conflict check live in one place.
|
||||
api.registerEventLeases([{
|
||||
id: 'uo.rate.skillgain', label: 'Skill gain rate',
|
||||
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
|
||||
async read() { return { ok: true, value: 1.0 } },
|
||||
async apply(v, until) { return { ok: true } },
|
||||
async restore(baseline, { expected }) { return { ok: true } },
|
||||
async inForce() { return { ok: true, held: true } }, // optional
|
||||
}, {
|
||||
// A TARGETED lease: one capability over many things. Core adds the target to
|
||||
// the reservation ref (`<lease id>#<target>`) so two runs may hold the same key
|
||||
// on two different objects, and hands it to all four callables.
|
||||
id: 'uo.spawner.maxcount', label: 'Spawner: how many at once',
|
||||
type: 'int', min: 0, max: 100, maxDurationMs: 43200000,
|
||||
target: { label: 'Which spawner', source: 'uo.options.spawners' },
|
||||
async read({ target }) { return { ok: true, value: '3' } },
|
||||
async apply(v, until, { target }) { return { ok: true } },
|
||||
async restore(baseline, { expected, target }) { return { ok: true } },
|
||||
async inForce({ target }) { return { ok: true, held: true } },
|
||||
}, {
|
||||
// A string lease may close its value set. `min`/`max` bound the numeric types
|
||||
// and nothing bounded `string`, so without this the only check on the value is
|
||||
// the game side's -- a refusal arriving unattended, mid-run, rather than on the
|
||||
// authoring form.
|
||||
id: 'uo.seasonal.status', label: 'Seasonal event status',
|
||||
type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000,
|
||||
target: { label: 'Which seasonal event', source: 'uo.options.seasonal' },
|
||||
async read({ target }) { return { ok: true, value: 'Inactive' } },
|
||||
async apply(v, until, { target }) { return { ok: true } },
|
||||
async restore(baseline, { expected, target }) { return { ok: true } },
|
||||
}])
|
||||
```
|
||||
|
||||
**What a module author has to know beyond the four names**, because each is a rule rather than a
|
||||
field:
|
||||
|
||||
- **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs`
|
||||
timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that
|
||||
needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its
|
||||
own transport's timeout** — see §2.4's rule, which exists because the first module to register an
|
||||
action did not, and its one non-retryable verb was retried anyway. That is
|
||||
`registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core
|
||||
showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded
|
||||
as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that
|
||||
means "this will never work" says `retry: false`.
|
||||
- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering
|
||||
`{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything
|
||||
else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone.
|
||||
- **A lease that declares a `target` is a family of values, and core changes what it reserves.**
|
||||
Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a
|
||||
config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands
|
||||
of spawners and reserving the id would let one run turning up one spawner refuse every other run
|
||||
every other spawner. With one, the ref is `<lease id>#<target>`, the two-events-one-target index
|
||||
bites at the granularity the world actually has, and the target reaches all four callables.
|
||||
**Core refuses a targeted lease with no target and an untargeted one with a target**, both
|
||||
`retry: false`: the second attempt has the same params. `target.source` names an option source
|
||||
for the authoring form, and is not resolved by core at registration — a source registered by a
|
||||
module that boots later must not make this one throw.
|
||||
- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the
|
||||
numeric types; a set on an int lease would be a second bound beside them with no rule about
|
||||
which wins, so it is refused.
|
||||
- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this
|
||||
existed behaves identically. Declare `searchable: true` when the term actually narrows the
|
||||
answer — the form reads that to decide between a typeahead and a select, and inferring it from a
|
||||
truncated list would read correctly right up until a small deployment's list happened to fit.
|
||||
Core needs it because a reconcile after an outage asks *"does the game side still have any record
|
||||
of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run
|
||||
applied is drift, which `restore()` reports so the row lands `drifted` with the current value
|
||||
beside it, and a reconcile that inferred absence from a changed value would orphan the row first
|
||||
and tell the operator the lease vanished rather than that somebody moved it. The two questions
|
||||
have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's
|
||||
step names `core.lease`, which is core's own action, so there is nowhere else a module could hang
|
||||
the answer.
|
||||
- **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module
|
||||
registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because
|
||||
the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same
|
||||
as bounding it: a declared dimension with no operator cap is counted and unbounded.
|
||||
- **`verify: true` must change nothing and must answer honestly.** It rides the same dispatcher a
|
||||
real run uses, because a dry run down a second code path is a dry run of the second path.
|
||||
- **`example` is required on every param, optional ones included** — the same rule
|
||||
`registerEventTriggers` makes of a variable's example, for the same reason: it is the authoring
|
||||
form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards.
|
||||
- **Actions, budgets, leases and option sources are four separate id spaces**, each namespaced
|
||||
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
|
||||
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
|
||||
would forbid the most natural set of names a module will ever write.
|
||||
- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the
|
||||
module never writes one: core reads the baseline, reserves the target in the resource ledger —
|
||||
which is where "two events cannot hold one target" comes from, as a unique index rather than as a
|
||||
check — applies the value with the deadline, and restores it at teardown through the module's own
|
||||
`restore()`. A lease verb per module would be that bound re-implemented once per module, advisory
|
||||
everywhere, and wrong in the first one that forgot it.
|
||||
- **`until` goes down the wire, and the game side must honour it without being asked again.** A
|
||||
module that treats it as advisory has produced a lease that outlives an outage, which is the one
|
||||
thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is
|
||||
the fail-safe.
|
||||
- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core
|
||||
records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was
|
||||
lost leaves a row for something that may never have existed — and cleanup will ask about it. A
|
||||
module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style
|
||||
monthly wipe needs, and the second reason a lease's restore must be idempotent too.
|
||||
- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer
|
||||
case: core knows a dispatch went out under that key and never learned what it made. A module that
|
||||
can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible
|
||||
to an operator, which is the correct outcome rather than a silent one.
|
||||
- **`reconcile` is optional where `revert` is required**, and the asymmetry is the design. A module
|
||||
that cannot say what the game still has is not broken — core keeps believing its own ledger, which
|
||||
is the behaviour before this version — whereas one that created something and cannot undo it has
|
||||
made a promise core has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }`
|
||||
leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module
|
||||
reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go.
|
||||
|
||||
**A third joined in Phase 15, also on an envelope:**
|
||||
|
||||
- **`detail` on an action's SUCCESS envelope** (`EVENTS.md` §F). An optional object a module may
|
||||
answer with, carried to the run log as a `step.detail` line and **never interpreted by core** —
|
||||
nothing reads a key out of it in the dispatcher, the runner or the browser. It exists because a
|
||||
module knows things about its own verb core cannot compute and had no other way to say them:
|
||||
`uo.item.grant` reaches the players a run's participation ledger holds, and *which of them missed
|
||||
out* was reported nowhere at all. On both success shapes, like `resources` and `participants`,
|
||||
because `await: 'human'` is a success and a cue's confirm finishes the step without a second
|
||||
dispatch.
|
||||
|
||||
Objects only, 4KB of serialised JSON, dropped rather than truncated, and **anything wrong with it
|
||||
is dropped and logged rather than failing the step** — a step that did what it was asked must not
|
||||
be re-run because its module's commentary was malformed, which would be a world write repeated for
|
||||
a log line. It is additive and optional: a module that never answers one is behaving exactly as
|
||||
before. **Found by writing the integration kit's chapter 5** (`EVENTS_PLAN.md` Phase 15), whose
|
||||
template made the same mistake `module-uo` had — see §F.
|
||||
|
||||
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
|
||||
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
|
||||
written against 1.8.0 keeps working and simply seeds nothing.
|
||||
@@ -142,6 +352,64 @@ branches lacks — so it is the only place `permits` is true between two values
|
||||
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
|
||||
unchanged, and so is every rule about composition narrowing rather than widening.
|
||||
|
||||
**1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
|
||||
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
|
||||
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
|
||||
`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost nothing
|
||||
was added to `ctx`: an action is called BY core, so what a module needs from this contract it is
|
||||
handed in the envelope rather than reaching for — `ctx.events.reconcile()` (Phase 8) is the one
|
||||
exception, because only the module knows when the game it talks to has restarted.
|
||||
|
||||
Two members joined it in Phase 10, both on an envelope:
|
||||
|
||||
- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer
|
||||
`{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records
|
||||
them against the run, on both success shapes, beside `resources`. `memberKey` is required and
|
||||
module-opaque; `userId` is optional and is the module's own answer to "is this player a website
|
||||
account", because core cannot map one and a core that guessed would be one game's identity model
|
||||
compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches
|
||||
a world write that already happened.
|
||||
- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for
|
||||
THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`,
|
||||
so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings
|
||||
meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about
|
||||
which branch was meant. The case that forced it is core's own — a rehearsal fires the same
|
||||
lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared
|
||||
envelope rather than in `events/` because "this particular firing is narrower than the kind
|
||||
usually is" is a fact any emitter can have.
|
||||
|
||||
Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended
|
||||
into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there
|
||||
is no deployment that could tell the difference, and the events cutover is what publishes the
|
||||
whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose
|
||||
and stays so until the cutover re-pins `ci/core-ref.json`.
|
||||
|
||||
- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a
|
||||
FAMILY of values rather than a single value, and core reserves `<lease id>#<target>` rather than
|
||||
the id — so two runs may hold the same key on two different objects while two runs holding one
|
||||
object still collide on the unique index. The target reaches `read`, `apply`, `restore` and
|
||||
`inForce`. Every lease before this named one value, so the id *was* the target and none of the
|
||||
four needed an argument; a property does not have that shape.
|
||||
|
||||
**The verb stays core's**, which is the whole reason this is an extension rather than a lease
|
||||
verb of the module's own. §F settled that in Phase 8: a lease verb per module would
|
||||
re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and
|
||||
wrong in the first one that forgot. Half of that objection no longer holds — the
|
||||
two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the
|
||||
row — and the other half still does.
|
||||
- **`values` on a `string` lease.** The closed set an author may choose from, checked by
|
||||
`core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded
|
||||
`string`, so the only check on a string lease's value was the game side's — a refusal arriving
|
||||
unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside
|
||||
`min`/`max` would be a second bound with no rule about which wins.
|
||||
- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose
|
||||
catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the
|
||||
argument answers exactly as it did before this existed, which is what makes it additive. The
|
||||
first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the
|
||||
2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says
|
||||
nothing about which part. `searchable` is declared rather than inferred, because inferring it
|
||||
from a truncated answer reads correctly right up until a small deployment's list happens to fit.
|
||||
|
||||
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
|
||||
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
|
||||
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
|
||||
@@ -489,6 +757,10 @@ api.registerSlashCommands([{ name, description, options, access, handler }]) //
|
||||
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
|
||||
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
|
||||
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
|
||||
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
|
||||
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
|
||||
api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0
|
||||
api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
@@ -861,6 +1133,88 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
|
||||
in `channels`. Every other key must be one of the rule's channels.
|
||||
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
|
||||
|
||||
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
|
||||
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
|
||||
shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract
|
||||
rather than implementation and belong here:
|
||||
|
||||
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
|
||||
dispatch, and unlike everything above them — but from further away than either, because the thing
|
||||
on the other end may be a shard. That is why `budgetMs` is declared per action and enforced by the
|
||||
dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim
|
||||
until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar
|
||||
becomes an infinite loop rather than a failed step.
|
||||
- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The
|
||||
dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action —
|
||||
it cannot, the action is still awaiting a socket. So an action whose own client gives up *after*
|
||||
core's deadline never gets to classify its own failure, and `retry: false` in its envelope is
|
||||
unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which
|
||||
is the wrong way round: every slow shard produced a retry the module had explicitly refused. The
|
||||
rule generalises past that one pairing — an action is the near end of a call with a far end, and
|
||||
the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it
|
||||
is attempted once, declares 15000.
|
||||
- **A module's `detail` is carried and never read.** An optional object on either success shape,
|
||||
bounded at the dispatcher and written to the run log verbatim beside the action id. Core reads no
|
||||
key out of it — a switch on known keys anywhere in core would be core learning one module's
|
||||
vocabulary, which is the thing this whole contract exists to prevent. It is the answer to *"what
|
||||
actually happened"* for a verb whose answer is neither a resource nor a participant, and before
|
||||
Phase 15 there was no such answer: `EVENTS.md` §H named the member, `classify()` had never read
|
||||
one, and a module that used it wrote into nothing.
|
||||
- **A module reports who took part on the envelope, and there is no other door.** `participants`
|
||||
rides back from `perform()` exactly as `resources` does, on both success shapes — including
|
||||
`await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is
|
||||
therefore the only moment its participants can be recorded. There is deliberately no
|
||||
`ctx.events.participants` and no route: a second write path into a run core is mid-tick on would be
|
||||
a second thing that can race the step claim. One step may report at most 5000, the same bound the
|
||||
engagement engine puts on a list of users a caller may assert, and a member reported twice in one
|
||||
step is recorded once with the duplicate named.
|
||||
- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An
|
||||
emitter that already knows this particular firing must not reach as far as the declaration allows
|
||||
passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing —
|
||||
and passing something incomparable with the declaration refuses every rule rather than resolving
|
||||
to either. See §1.1 under 1.10.0.
|
||||
- **`once`, on all four.** A batch is a module's complete statement about what it declares; a second
|
||||
call is a module changing its mind halfway through `register()` rather than adding to it. And they
|
||||
STAGE, like every registration above: a module that registers two budgets and then throws has left
|
||||
nothing behind.
|
||||
- **Everything is optional, and §F says so once because it governs every member.** A module may
|
||||
register no actions, no budgets, no leases and no option sources. Each registration *adds* what an
|
||||
author can reach for; a module that omits one costs its deployment a capability rather than a boot,
|
||||
exactly as a module with no `onBoot` still reaches `started`.
|
||||
- **An option source that refuses degrades its field to free text with a warning.** It never blocks
|
||||
the authoring form and it never raises. The alternative is a screen a module's outage can take
|
||||
away, for a field whose value the operator very often already knows — which is a worse failure than
|
||||
the typo the dropdown exists to prevent.
|
||||
- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D
|
||||
rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core
|
||||
wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer
|
||||
never came back is still something cleanup can act on. The consequence for a module author is the
|
||||
whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called
|
||||
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
|
||||
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
|
||||
module that cannot answer it says so, and the row stays visible to an operator.
|
||||
- **That key IDENTIFIES a dispatch; it is not a key to send on the undo.** It names the command core
|
||||
lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of
|
||||
the reverting command is a different thing entirely, and on a game whose at-most-once store keys on
|
||||
the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and
|
||||
answered with the original reply. `module-uo` made exactly this mistake: teardown of all five world
|
||||
verbs was a no-op that reported success, because every despawn carried the key its spawn had gone
|
||||
out under. Found by the Phase 16 acceptance walk, with the ledger reading `reverted` and the shard
|
||||
still holding every object. A command that undoes needs a key of its own or none at all; a repeated
|
||||
undo is usually harmless by construction ("already gone" is a success), which is what makes *none*
|
||||
the right answer more often than not.
|
||||
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
|
||||
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
|
||||
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it
|
||||
runs on every terminal path — completion, cancellation and abort alike — so a module's only job is
|
||||
to answer `revert` correctly however many times it is asked.
|
||||
|
||||
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
|
||||
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant
|
||||
trigger — and a dormant step blocks the PUBLISH, because a version is what a run pins and a run
|
||||
cannot dispatch a verb nobody registers. A step that reaches dispatch naming one fails `terminal`
|
||||
with the module named and the run degrades: never a silent skip.
|
||||
|
||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||
|
||||
### 2.5 Lifecycle
|
||||
@@ -1103,6 +1457,17 @@ Four fields, in the loader's scan order (§4.2). What is *not* there is the desi
|
||||
string. A client must treat an unknown capability as absent and must not infer a route from one — the
|
||||
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
|
||||
|
||||
**Core publishes a capability list of its own, and it is deliberately not this one.** Since events
|
||||
Phase 14a, `GET /public/version` — and `GET /public/status`, which embeds the same block — carries a
|
||||
`capabilities` array naming what CORE serves beyond the baseline every backend has. It is the same
|
||||
idea and the same word so that a client feature-detects one way rather than two, and a **separate
|
||||
list** because core is not a module: publishing core here as a pseudo-module would leave a client
|
||||
unable to tell *"this backend has events"* from *"a module called core happens to be installed"*,
|
||||
which is exactly the distinction this endpoint exists to make. The value in core's list is in what is
|
||||
absent — a backend released before a capability existed omits the key entirely, which is how a client
|
||||
tells an older site from one that simply has nothing to show. The same rule applies to both: an
|
||||
unknown string is absent, and no route may be inferred from one.
|
||||
|
||||
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
|
||||
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
|
||||
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route
|
||||
|
||||
Reference in New Issue
Block a user