Compare commits
1 Commits
edge
...
docs/engag
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd5d065b7 |
@@ -64,10 +64,7 @@ particular game; a module is what makes it a site *for* one.
|
||||
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
|
||||
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
|
||||
| [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` |
|
||||
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`). **The current protocol** |
|
||||
| [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 |
|
||||
|
||||
236
android/PLAN.md
236
android/PLAN.md
@@ -661,8 +661,6 @@ 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:
|
||||
@@ -674,15 +672,6 @@ 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
|
||||
@@ -703,11 +692,6 @@ 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
|
||||
@@ -775,18 +759,6 @@ Mirrors the website's "degrade gracefully" invariant:
|
||||
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
|
||||
added later without reworking the repository layer (its typed results already isolate the UI from the
|
||||
data source). No `Room` dependency in the initial build.
|
||||
- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app
|
||||
inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push
|
||||
code already uses, capped at the server's own default page size. An inbox is a short, read-only,
|
||||
newest-first list with a server-side cursor and no joins, so what "works offline" needs is the
|
||||
newest page and the badge, not a database. **Every snapshot is scoped to (base URL, user id)** and
|
||||
handed back only to that pair, which is what stops one account's notifications surfacing under
|
||||
another's session on the teardown paths that never reach a logout (a dead refresh token, a server
|
||||
switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard.
|
||||
**The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of
|
||||
`RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't
|
||||
reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's
|
||||
startup model, and it was left for the org lead rather than widened into Phase 8.
|
||||
|
||||
---
|
||||
|
||||
@@ -1144,169 +1116,6 @@ 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
|
||||
@@ -1498,48 +1307,9 @@ Four properties the UI should be built on rather than around:
|
||||
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
|
||||
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
|
||||
|
||||
### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31)
|
||||
|
||||
**The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its
|
||||
gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content,
|
||||
`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS`
|
||||
is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the
|
||||
old route still lands somewhere sensible.
|
||||
|
||||
**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7
|
||||
shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being
|
||||
read), the unread count that rides along on every page, and the two mark-read writes. Reads are
|
||||
optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or
|
||||
going offline right after reading everything would bring the badge back on the next cold open. The
|
||||
drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than
|
||||
on a timer - the tickle is what says "something happened", so polling would be a second, worse copy
|
||||
of push.
|
||||
|
||||
**Two things the app has to do that the backend contract does not state:**
|
||||
|
||||
- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`),
|
||||
which is right for a browser already on the site and a dead link on a phone.
|
||||
`InboxViewModel.linkFor` resolves it against the configured base with OkHttp's `HttpUrl.resolve`,
|
||||
which absolutises the path *and* returns null for anything that would not end up http(s) - so a
|
||||
`javascript:` or `intent:` url in a notification body opens nothing. The live rig is what caught
|
||||
this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing
|
||||
at all.
|
||||
- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER
|
||||
id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight
|
||||
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
||||
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
||||
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
||||
that a row exists, and the contract stays wake-and-pull.
|
||||
|
||||
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
||||
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
||||
one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from
|
||||
**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the
|
||||
app and a fourth channel would too, without a release. A trigger-only id shows no push control rather
|
||||
than a dead switch, and on a shard with no push relay the push controls are absent with the reason in
|
||||
a note beside the list (email and on-site preferences are still worth setting there). Each change is
|
||||
one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops
|
||||
shows up as the control springing back.
|
||||
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
|
||||
per-channel toggles. Until then the existing per-stream screen keeps working against
|
||||
`/notifications/subscriptions` unmodified.
|
||||
|
||||
## 12. Build & CI (Gitea Actions)
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ android-app/
|
||||
│ │ │ │ │ │ ├── SessionManager.kt
|
||||
│ │ │ │ │ │ ├── TokenStore.kt
|
||||
│ │ │ │ │ │ └── TrustTokenStore.kt
|
||||
│ │ │ │ │ ├── inbox/
|
||||
│ │ │ │ │ │ ├── DataStoreInboxCache.kt
|
||||
│ │ │ │ │ │ └── InboxCache.kt
|
||||
│ │ │ │ │ ├── net/
|
||||
│ │ │ │ │ │ ├── AuthInterceptor.kt
|
||||
│ │ │ │ │ │ ├── BaseUrlHolder.kt
|
||||
@@ -172,12 +169,8 @@ android-app/
|
||||
│ │ │ │ │ │ ├── PostScreen.kt
|
||||
│ │ │ │ │ │ └── PostViewModel.kt
|
||||
│ │ │ │ │ ├── notifications/
|
||||
│ │ │ │ │ │ ├── InboxBadgeViewModel.kt
|
||||
│ │ │ │ │ │ ├── InboxFormatting.kt
|
||||
│ │ │ │ │ │ ├── InboxScreen.kt
|
||||
│ │ │ │ │ │ ├── InboxViewModel.kt
|
||||
│ │ │ │ │ │ ├── NotificationSettingsScreen.kt
|
||||
│ │ │ │ │ │ └── NotificationSettingsViewModel.kt
|
||||
│ │ │ │ │ │ ├── NotificationsScreen.kt
|
||||
│ │ │ │ │ │ └── NotificationsViewModel.kt
|
||||
│ │ │ │ │ ├── page/
|
||||
│ │ │ │ │ │ ├── BlockRenderer.kt
|
||||
│ │ │ │ │ │ ├── PageScreen.kt
|
||||
@@ -335,7 +328,6 @@ android-app/
|
||||
│ │ │ │ │ └── WikiDtoTest.kt
|
||||
│ │ │ │ └── fake/
|
||||
│ │ │ │ ├── FakeAdminApi.kt
|
||||
│ │ │ │ ├── FakeNotificationsApi.kt
|
||||
│ │ │ │ ├── FakePlayerShardApi.kt
|
||||
│ │ │ │ ├── FakePublicApi.kt
|
||||
│ │ │ │ └── FakeShardStream.kt
|
||||
@@ -363,10 +355,7 @@ android-app/
|
||||
│ │ │ │ ├── NavPathsTest.kt
|
||||
│ │ │ │ └── NavTreeTest.kt
|
||||
│ │ │ ├── notifications/
|
||||
│ │ │ │ ├── InboxFormattingTest.kt
|
||||
│ │ │ │ ├── InboxViewModelTest.kt
|
||||
│ │ │ │ ├── NotificationRoutingTest.kt
|
||||
│ │ │ │ └── NotificationSettingsViewModelTest.kt
|
||||
│ │ │ │ └── NotificationRoutingTest.kt
|
||||
│ │ │ ├── player/
|
||||
│ │ │ │ ├── CharacterSheetHelpersTest.kt
|
||||
│ │ │ │ ├── CharactersViewModelTest.kt
|
||||
@@ -388,7 +377,6 @@ android-app/
|
||||
│ │ │ └── UiStateTest.kt
|
||||
│ │ ├── util/
|
||||
│ │ │ ├── FakeApiSupport.kt
|
||||
│ │ │ ├── FakeInboxCache.kt
|
||||
│ │ │ └── MainDispatcherRule.kt
|
||||
│ │ └── ScaffoldSanityTest.kt
|
||||
│ ├── build.gradle.kts
|
||||
|
||||
@@ -21,12 +21,6 @@ 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
|
||||
@@ -290,11 +284,6 @@ 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.
|
||||
@@ -303,70 +292,6 @@ 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,26 +55,20 @@ 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 **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".
|
||||
The current version is **4**, shipped in sidecar **v2.0.0** and overlay **v1.0.0**.
|
||||
|
||||
- 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**:
|
||||
- Every response carries an **`X-UOLink-Version: 4`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`.
|
||||
- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```json
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 4, "client_protocol": "3" }
|
||||
```
|
||||
|
||||
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
|
||||
@@ -88,36 +82,6 @@ the wire. Additive in the same shape as the previous two bumps — nothing that
|
||||
so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once
|
||||
it declares `4`.
|
||||
|
||||
**v5 (Protocol 5)** adds three things at once ([`v5.md`](v5.md)) — `house.decay` gains `ownerName`
|
||||
and a `schedule`, `vendor.listing` gains `ownerAcct` and a `fees` block, and `account.login.result`
|
||||
is a new kind carrying the verdict its long-standing `account.login.attempt` companion fires too
|
||||
early to know. Three at once because a bump costs a release, a bundle and an operator update on
|
||||
every shard, so a field left out costs a whole second round of that.
|
||||
|
||||
Additive again: no existing field changed shape, and **no new endpoint** — every v5 addition rides
|
||||
kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes,
|
||||
both about ABSENCE rather than presence, because both are easy to read as an error:
|
||||
|
||||
- `schedule.estimatedCollapse` is **omitted whenever it is not exactly knowable** — which, on a
|
||||
dynamic-decay shard, is every stage before IDOC.
|
||||
- `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
|
||||
@@ -204,8 +168,7 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
||||
|------|--------|
|
||||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
||||
| `mob.logout` | `who` |
|
||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt, fired from a sink that runs **before** the auth decision, so it fires on successful logins too. Use `account.login.result` for the verdict. No password ever leaves the shard |
|
||||
| `account.login.result` | `acct`, `ip`, `accepted`, `reason` — **Protocol 5.** The verdict of the attempt above, which the attempt structurally cannot carry. `reason` is an `ALRReason` (`BadPass`, `Invalid`, `Blocked`, `InUse`, `BadComm`) and is **present only when `accepted` is false**, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind |
|
||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
||||
|
||||
#### Economy & commerce
|
||||
| kind | fields | notes |
|
||||
@@ -241,36 +204,15 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
||||
#### Housing / IDOC
|
||||
| kind | fields |
|
||||
|------|--------|
|
||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
||||
|
||||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
||||
|
||||
\* **Protocol 5.** `ownerName` is the owner's character name (`ownerAcct` is the game account, and
|
||||
the only one of the two that identifies a person). `schedule` is a nested object:
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `dynamicDecay` | whether this shard runs ServUO's dynamic decay (`Core.ML`). Always present |
|
||||
| `nextStage` | ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock |
|
||||
| `decayPeriodSec` | seconds from a full refresh to collapse; lets a reader turn `lastRefreshed` into a percentage |
|
||||
| `estimatedCollapse` | ISO-8601 UTC — **present only when it is exact**, see below |
|
||||
|
||||
**`estimatedCollapse` is absent far more often than not, and that is deliberate.** Under dynamic
|
||||
decay ServUO draws each stage's duration at *random* when the stage is entered, so collapse is
|
||||
exactly knowable only once the house is already at `IDOC` — at which point the next transition is
|
||||
the collapse. Under static decay it is a pure function of `lastRefreshed + decayPeriodSec` and is
|
||||
exact at every stage. It is omitted rather than approximated, because an absent field is honest
|
||||
where a wrong date is a dated promise. **Treat its absence as "not knowable", never as "not yet
|
||||
read"** — and never fall back to computing one yourself under dynamic decay.
|
||||
|
||||
```json
|
||||
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
|
||||
"map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
|
||||
"ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
|
||||
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
|
||||
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
|
||||
"ban":{"x":1482,"y":1604,"z":0},
|
||||
"builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"}
|
||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
||||
```
|
||||
|
||||
#### Economy supply (periodic)
|
||||
@@ -316,7 +258,6 @@ 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).
|
||||
@@ -344,43 +285,6 @@ 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.
|
||||
@@ -619,46 +523,14 @@ shop name or location actually change.
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `ownerAcct`*, `location{}`, `fees{}`*, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
|
||||
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
|
||||
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
|
||||
|
||||
\* **Protocol 5.** `ownerAcct` is the owner's game account — `ownerName` is a character name and
|
||||
identifies nobody, so this is the field that makes a shop resolvable to a person at all.
|
||||
|
||||
`fees` describes ServUO's vendor dismissal rule (`PlayerVendor.PayTimer`: at each tick the charge
|
||||
is compared with the funds, and the vendor is destroyed when the charge wins):
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `exempt` | `true` for a commission vendor, which has no pay timer and is **never** dismissed for fees. When true, no other field is present |
|
||||
| `newVendorSystem` | which of ServUO's two vendor systems is in force; it decides all three quantities below |
|
||||
| `chargePerPeriod` | what is deducted at each tick |
|
||||
| `funds` | gold available to pay it (`holdGold` and `bankAccount` are the raw parts) |
|
||||
| `payIntervalSec` | seconds between ticks: 86400 under the new system, **one UO day (≈2 real hours)** under the old |
|
||||
| `nextPayAt` | ISO-8601 UTC: the next tick |
|
||||
| `periodsRemaining` | ticks survived before the one that finds the charge unpayable |
|
||||
| `dismissalAt` | ISO-8601 UTC: the tick the vendor is destroyed on. **This is the field to build on** |
|
||||
|
||||
**There is deliberately no `daysRemaining`**: under the old vendor system a pay period is a UO day,
|
||||
so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice.
|
||||
`dismissalAt` is an instant and needs no units. It assumes no further sales or deposits — but
|
||||
unlike `house.decay`'s `estimatedCollapse` there is no randomness in it: given the current funds
|
||||
it is exact.
|
||||
|
||||
**Treat `exempt: true` and a distant `dismissalAt` as different things.** "Never dismissed" and
|
||||
"dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire
|
||||
ends up in an expiry warning.
|
||||
|
||||
```json
|
||||
{"kind":"vendor.listing","serial":"0x40001234",
|
||||
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||||
"ownerAcct":"darrow_acct",
|
||||
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||||
"region":"Britain","house":"Darrow's Villa"},
|
||||
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,
|
||||
"funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
|
||||
"nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7,
|
||||
"dismissalAt":"2026-09-08T21:01:21Z"},
|
||||
"count":2,"total":2,"truncated":false,
|
||||
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
||||
"price":25000,"name":null,"cliloc":1023721},
|
||||
@@ -937,128 +809,6 @@ 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
|
||||
@@ -1254,10 +1004,8 @@ 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 |
|
||||
| 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) |
|
||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||||
| 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 |
|
||||
@@ -1265,11 +1013,6 @@ 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
|
||||
@@ -1277,7 +1020,7 @@ so a retry loop cannot quietly swallow a mismatched deployment.
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
84
link/PLAN.md
84
link/PLAN.md
@@ -103,7 +103,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an
|
||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | IP signals for the website. **`AccountLogin` cannot give you a FAILED login**, which this row assumed it could: it is a veto hook that fires *before* the auth decision, and `AccountLoginEventArgs` constructs with `Accepted = true`, so a handler reading the verdict there reports every login as accepted. Protocol 5 adds `account.login.result`, read one Core slice later — [`v5.md`](v5.md) §2.3. |
|
||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
||||
|
||||
@@ -133,7 +133,6 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an
|
||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
||||
| **Vendor fee state** | vendor sweep (Protocol 5) | periodic | The dismissal deadline, not just the balance. `PlayerVendor.PayTimer` destroys a vendor when `pay > totalGold`, and **both halves differ between ServUO's two vendor systems** — `ChargePerRealWorldDay` vs `ChargePerDay`, `HoldGold` vs `BankAccount + HoldGold`, a real day vs a UO day. Resolved on the shard into a `dismissalAt` instant; see [`v5.md`](v5.md) §2.2. `IsCommission` vendors have no pay timer at all. |
|
||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
||||
|
||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
||||
@@ -145,7 +144,6 @@ Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
||||
| **Decay schedule** | same sweep (Protocol 5) | with the above | `NextDecayStage`, `DecayPeriod`, and a collapse time **only where one exists** — see the note below. |
|
||||
|
||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
||||
|
||||
@@ -155,20 +153,6 @@ Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerS
|
||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
||||
|
||||
**What that model means for publishing a collapse time (Protocol 5).** Because each stage's
|
||||
duration is drawn at random *when the stage is entered*, `NextDecayStage` is exact for the next
|
||||
transition and **nothing beyond it is known at all**. A collapse time is therefore exact only once
|
||||
the house is already at IDOC, where the next transition is the collapse. `house.decay` omits
|
||||
`estimatedCollapse` everywhere else rather than approximating it — the field's absence means "not
|
||||
knowable", never "not yet read". On a shard where `DynamicDecay.Enabled` is false the dead code
|
||||
above is live instead, and collapse is exact at every stage; the frame carries `dynamicDecay` so a
|
||||
consumer can tell which regime it is reading. [`v5.md`](v5.md) §2.1.
|
||||
|
||||
**The `CanDecay` rule above is also the main trap when TESTING this.** Forcing a stage with
|
||||
`SetDynamicDecay` on an `AutoRefresh` house does nothing observable: the `DecayLevel` getter calls
|
||||
`ResetDynamicDecay()` and reports `Ageless`, so the forced stage is wiped before the next sweep
|
||||
reads it and **no frame is emitted at all** — which looks exactly like a broken emitter.
|
||||
|
||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
||||
|
||||
### 5.5 Combat, death, PvP
|
||||
@@ -178,16 +162,10 @@ 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. **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. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||||
| 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
|
||||
|
||||
@@ -255,13 +233,6 @@ Three edits, then the bridge stays pure-subscription:
|
||||
|
||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||||
|
||||
> **The frames below are 1.0's design sketch, not the shipped wire.** They have drifted in the four
|
||||
> versions since — `house.decay` in particular ships a flattened `ownerSerial`/`ownerAcct`/`ownerName`
|
||||
> rather than an `owner` object, and from Protocol 5 its `nextStage` lives inside a nested `schedule`
|
||||
> alongside `decayPeriodSec`, `dynamicDecay` and a conditional `estimatedCollapse`. The normative
|
||||
> field lists are [`INTEGRATION.md`](INTEGRATION.md) §Event catalog, with each bump's rationale in
|
||||
> [`PROTOCOL_2.md`](PROTOCOL_2.md), [`v3.md`](v3.md), [`v4.md`](v4.md) and [`v5.md`](v5.md).
|
||||
|
||||
### Outbound (shard → sidecar)
|
||||
|
||||
```jsonc
|
||||
@@ -301,57 +272,6 @@ 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.
|
||||
|
||||
279
link/v5.md
279
link/v5.md
@@ -1,279 +0,0 @@
|
||||
# Protocol 5 — Three enrichments, one bump
|
||||
|
||||
**Status:** Released. Cut over to `main` on 2026-09-01 and shipped in sidecar **v2.1.0** + overlay **v1.1.0**, paired as bundle **2026.09.01**.
|
||||
**Date:** 2026-08-31
|
||||
**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), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||
**Driven by** [`../website/ENGAGEMENT.md`](../website/ENGAGEMENT.md) Phase 10.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why three things at once
|
||||
|
||||
Every previous bump did one thing. This one does three, and the reason is a property of the bump
|
||||
itself rather than of the features:
|
||||
|
||||
**A protocol bump costs a sidecar release, a republished bundle, and an operator update on every
|
||||
shard.** A field left out does not cost a follow-up commit — it costs a *second* bump with the same
|
||||
three-part lead time, and an operator population split across two protocol versions until the second
|
||||
one finishes propagating. So everything the engagement workstream is known to need from the wire was
|
||||
decided before any of it was written, and all of it rides v5.
|
||||
|
||||
The three were not picked from a wish list. Each is a field that a **specific Phase 11 trigger cannot
|
||||
be built without**, and in two of the three cases that was discovered by reading the emitters rather
|
||||
than by reading the plan, which had recorded both as already possible.
|
||||
|
||||
| # | Enrichment | The trigger that needs it | What was actually there |
|
||||
|---|---|---|---|
|
||||
| a | `house.decay` gains `ownerName` + a `schedule` | `uo.house.idoc_warning` — the flagship | The frame could say a house had *become* IDOC and nothing about when it ends |
|
||||
| b | `vendor.listing` gains `ownerAcct` + `fees` | `uo.vendor.expiring` | `ownerSerial` and `ownerName` only, and **no fee data anywhere on the wire** — the plan had this as "needs a mapper" |
|
||||
| c | `account.login.result`, a new kind | `uo.account.login_failed` | `account.login.attempt` fires **before the auth decision**, so it fires on successful logins too |
|
||||
|
||||
### 1.1 What was deliberately left out
|
||||
|
||||
**`vendor.sale` stays in the opt-in patch tier.** It is real, and it already carries `ownerAcct` —
|
||||
but it lives in `servuo-plugins/patches/`, verified only against ServUO 57.4, because it needs a
|
||||
`PlayerVendorSale` EventSink that the patches *add* to core. Moving it into `overlay/` was considered
|
||||
and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core
|
||||
patch as though it were an overlay file. The consequence is a documentation obligation, not a
|
||||
protocol one — `uo.vendor.sale` is a trigger that is **silently dormant on a shard that declined the
|
||||
tier**, and the rule that names it has to say so.
|
||||
|
||||
---
|
||||
|
||||
## 2. The shard side
|
||||
|
||||
### 2.1 `house.decay` — a schedule, and only where it is knowable
|
||||
|
||||
`BridgeSweeps.WriteDecay` gains `ownerName` and one nested object:
|
||||
|
||||
```json
|
||||
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
|
||||
"name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
|
||||
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
|
||||
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
|
||||
"map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"}
|
||||
```
|
||||
|
||||
**`estimatedCollapse` is present only when it is exact**, and that is the whole design of this field.
|
||||
ServUO has two decay implementations and they differ in how knowable the future is:
|
||||
|
||||
- **Dynamic decay** (`DynamicDecay.Enabled`, i.e. `Core.ML`) draws each stage's duration at *random*
|
||||
when the stage is entered — `BaseHouse.SetDynamicDecay` calls `DynamicDecay.GetRandomDuration`. So
|
||||
`NextDecayStage` is exact for the **next** transition and nothing beyond it is known at all.
|
||||
Collapse becomes exact only once the house is already at IDOC, because then the next transition
|
||||
*is* the collapse.
|
||||
- **Static decay** (`GetOldDecayLevel`) is a pure function of `LastRefreshed` and `DecayPeriod`, so
|
||||
collapse is exact at **every** stage — there is no randomness to wait out.
|
||||
|
||||
Emitting a dynamic-decay house's collapse time at, say, `Fairly` would publish a guess as a fact, and
|
||||
on the website's side that becomes a dated promise in a player's mail. So it is **omitted rather than
|
||||
approximated**. An absent field is honest where a wrong date is not, and the consumer's
|
||||
`required: false` declaration already permits the absence.
|
||||
|
||||
`dynamicDecay` is emitted unconditionally so a reader can tell the two regimes apart, rather than
|
||||
having to infer them from which fields arrived.
|
||||
|
||||
**The nesting is load-bearing, not cosmetic.** The website's visibility projection matches literal
|
||||
JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys
|
||||
would be four rules that drift apart — the same argument that made `vendor.listing`'s `location`
|
||||
nested in v3.
|
||||
|
||||
### 2.2 `vendor.listing` — an owner who can be found, and a deadline
|
||||
|
||||
Two additions, and the first matters more than it looks:
|
||||
|
||||
**`ownerAcct`.** The frame has carried `ownerName` since v3, but a character name joins to nothing:
|
||||
the website's `shard_account_links` is keyed by the game **account**. Until now a vendor row named an
|
||||
owner the site could not resolve to a person, which is why a per-owner rule was impossible however
|
||||
much fee data existed.
|
||||
|
||||
**`fees`**, a nested object describing `PlayerVendor.PayTimer`'s dismissal rule. That rule is
|
||||
`if (pay > totalGold) Destroy()` at each tick — and *both halves of the comparison differ between
|
||||
ServUO's two vendor systems*:
|
||||
|
||||
| | charge | funds | interval |
|
||||
|---|---|---|---|
|
||||
| `NewVendorSystem` | `ChargePerRealWorldDay` | `HoldGold` | 1 real day |
|
||||
| old system | `ChargePerDay` | `BankAccount + HoldGold` | 1 UO day (`Clock.MinutesPerUODay`, ≈2 real hours) |
|
||||
|
||||
Both are resolved on the shard rather than left for the sidecar or the website to reconstruct,
|
||||
because reconstructing them anywhere else is a second implementation of a rule that lives in
|
||||
`PlayerVendor`.
|
||||
|
||||
```json
|
||||
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504,
|
||||
"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
|
||||
"nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7,
|
||||
"dismissalAt":"2026-09-08T21:01:21.6883320Z"}
|
||||
```
|
||||
|
||||
Two naming decisions worth recording, because the obvious spellings are both wrong:
|
||||
|
||||
- **There is no `daysRemaining`.** On an old-system shard a pay period is a UO day, so a field with
|
||||
"days" in its name would be off by a factor of twelve on exactly the shards least likely to notice.
|
||||
The wire carries `periodsRemaining` plus the interval that gives it meaning, and resolves the
|
||||
arithmetic into `dismissalAt` — an *instant*, which needs no units at all.
|
||||
- **`exempt` is not "a very long time left".** A commission vendor (`IsCommission`) has no `PayTimer`
|
||||
and is never dismissed for fees. It reports `{"exempt": true}` and no schedule, so a surface
|
||||
rendering "never" can tell it apart from one rendering "in 400 days".
|
||||
|
||||
`dismissalAt` assumes no further sales or deposits, exactly as a bank-balance projection does. Unlike
|
||||
a dynamic-decay house, though, there is **no randomness in it**: given the current funds it is the
|
||||
exact tick the vendor is destroyed on.
|
||||
|
||||
**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()`
|
||||
compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made
|
||||
of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A
|
||||
vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the
|
||||
consumer that exists for exactly that event could fire only by coincidence: when somebody happened to
|
||||
reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and
|
||||
fixed there (ENGAGEMENT.md, decision 13).
|
||||
|
||||
The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An
|
||||
integer division moves only when the shard's own answer to "is this vendor in danger" moves, while
|
||||
`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a
|
||||
fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape:
|
||||
no field was added and `PROTOCOL_VERSION` is untouched.
|
||||
|
||||
**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change
|
||||
detector, and a field added to the frame that is not added to the detector is a field that ships
|
||||
correct and arrives never.
|
||||
|
||||
### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed
|
||||
|
||||
`account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which
|
||||
`PacketHandlers.AccountLogin` invokes **before** it branches on `e.Accepted`. The emitter's own
|
||||
comment has said so since it was written: *"Fires before the auth decision, so this is an attempt,
|
||||
not a result."*
|
||||
|
||||
The verdict is set by the handlers themselves — `Server.Misc.AccountHandler` is the one that checks
|
||||
the password and sets `Accepted`/`RejectReason` — so inside our handler the verdict does not exist
|
||||
yet. `AccountLoginEventArgs` constructs with `Accepted = true`, which means a naive read there
|
||||
reports **every** login as accepted, including the ones about to be rejected.
|
||||
|
||||
That is why "someone tried to log into your game account" could not be built on the attempt: a rule
|
||||
on it would have mailed a security alert every time the player logged in successfully — the exact
|
||||
inversion that makes people stop trusting security mail.
|
||||
|
||||
```json
|
||||
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"}
|
||||
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true}
|
||||
```
|
||||
|
||||
**How it reads the verdict.** `Timer.DelayCall(TimeSpan.Zero, …)` over the args object: one Core
|
||||
slice later, `InvokeAccountLogin` has returned and the verdict is final. This needs no core patch,
|
||||
and — the reason it is preferred over simply subscribing late — it does not depend on handler
|
||||
**subscription order**, which ServUO does not define and which a shard's own scripts can change.
|
||||
|
||||
Three details that are deliberate:
|
||||
|
||||
- **`reason` is omitted on an accept.** `ALRReason`'s zero value is `Invalid`, so emitting it
|
||||
unconditionally would put a plausible-looking failure reason on every successful login.
|
||||
- **The IP is resolved inside the handler**, not in the deferred call: `AccountLogin_ReplyRej`
|
||||
disposes the `NetState` before the deferred read runs.
|
||||
- **The password is never read, never logged, never emitted.** The args object carries it, so it is
|
||||
held for one extra Core slice and no longer, and exactly two properties are read off it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The sidecar side
|
||||
|
||||
`PROTOCOL_VERSION: u32 = 4` → `5` in `sidecar/src/main.rs`, and **nothing else**.
|
||||
|
||||
There is **no store migration this time**, unlike v4. Every frame is persisted whole and the board
|
||||
tables index only the columns they already had, so the new fields ride inside the stored JSON and the
|
||||
new kind lands in `events` like any other. There is no kind allowlist to extend, either.
|
||||
|
||||
That is the dumb-forwarder property doing its job (v3 §3): the
|
||||
sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that
|
||||
touches one constant is the *expected* cost of an additive protocol version here; v4 needing a
|
||||
migration was the exception, because it added a column to a board table.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visibility
|
||||
|
||||
Three classifications, made on the website in `module-uo`'s `shardVisibility.js`, never in the
|
||||
sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently
|
||||
invisible with nobody noticing.
|
||||
|
||||
| Field / kind | Audience | Why |
|
||||
|---|---|---|
|
||||
| `house.decay` → `schedule` | **`anonymous`**, configurable | The countdown *is* the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it |
|
||||
| `vendor.listing` → `fees` | **`admin`**, configurable | The only default in the `market` feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce |
|
||||
| `vendor.listing` → `ownerAcct` | **admin, locked** | Rule 1, automatically: the key ends in `acct` |
|
||||
| `account.login.result` | **admin, by omission** | Rule 2 — a kind absent from `KIND_FEATURE` reaches nobody below admin |
|
||||
|
||||
**Why `fees` breaks the `market` pattern.** A shop's name, owner and location are already visible to
|
||||
any player through the stock in-game Vendor Search gump, which is the entire argument for publishing
|
||||
them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the
|
||||
**owner**, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure
|
||||
*and* a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in
|
||||
each.
|
||||
|
||||
**Why `account.login.result` is admin-only by omission rather than by mapping.** `KIND_FEATURE` is
|
||||
the map of kinds that *may* leave the admin channel, and an admin can widen anything on it. There is
|
||||
no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it
|
||||
at all would create a door that should not exist. The omission is the decision, and there is a test
|
||||
that says so by name.
|
||||
|
||||
**The REST reads are unchanged.** `shard_vendors`' new columns are not in any read model's column
|
||||
list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule
|
||||
*is* on the read model, re-nested under `schedule` on the way out so the stored shape and the live
|
||||
wire frame spell it identically — otherwise one admin rule would cover only one of the two paths,
|
||||
which is the §3.6.1 failure mode v3 already recorded once.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-repo obligations
|
||||
|
||||
| Repo | Change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | `BridgeSweeps.AppendDecaySchedule`, `BridgeMarket.AppendFees`, `BridgeEvents.EmitLoginResult` · **`overlay.toml` `protocol = 5`, in the same PR as the emitters** |
|
||||
| `link` | `PROTOCOL_VERSION` → 5 |
|
||||
| `module-uo` | ingest + schema (`shard_houses` ×4, `shard_vendors` ×8) · `shardVisibility.js` ×3 · the `uo_link_config` protocol pin, both declaration sites |
|
||||
| `docs` | this file · `INTEGRATION.md` §Housing, §Market, §Account · `PLAN.md` §5/§7 |
|
||||
| `runicgateway.com` | `platform.json.protocol` → 5 — **deferred to ENGAGEMENT.md Phase 12**, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
|
||||
|
||||
**The pin has three declaration sites and they are checked against each other, not against a
|
||||
literal.** `module-uo`'s schema test used to assert `DEFAULT 4` at each site, which is how protocol 4
|
||||
shipped with the emitters moved and one declaration left behind: every site agreed with itself. It
|
||||
now reads `DEFAULT_PROTOCOL` from the model, so the assertion is "the declarations **agree**" and a
|
||||
bump that misses one fails in CI instead of on an operator's install.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
Unit tests: 470 in `module-uo/server` (16 new), 39 in the sidecar, all passing; the C# compiles
|
||||
against the real ServUO 57.4 reference assemblies.
|
||||
|
||||
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/BridgeProtocol5Probe.cs`.
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC. The `Fairly` and `Greatly` frames carry `nextStage` and no `estimatedCollapse`; the `IDOC` frame carries both, equal |
|
||||
| `house.decay` carries the owner's character name | `"ownerName":"Zara Crowe"` on all three frames |
|
||||
| the fee block is right | `funds/chargePerPeriod` floors to `periodsRemaining` on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) |
|
||||
| a vendor with one tick left says so | `periodsRemaining: 0` with `dismissalAt == nextPayAt` — dismissed at the very next tick, which is the case `uo.vendor.expiring` exists for |
|
||||
| the login verdict is the FINAL one | A real socket login with a wrong password → `accepted:false reason:BadPass`; with the right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** |
|
||||
| the sidecar needed no change beyond the constant | `GET /health` → `"protocol":5`; every v5 field arrived through the generic forward path |
|
||||
| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in `installer`: `bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused` and `doctor.rs::a_protocol_mismatch_fails_the_row`. The check is version-agnostic, so v5 needs no installer change |
|
||||
|
||||
### 6.1 Two rig traps, both of which fake a broken emitter
|
||||
|
||||
Recorded because each cost a rebuild and each produces *silence* rather than an error.
|
||||
|
||||
- **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls
|
||||
`acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so
|
||||
firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password.
|
||||
Only a real socket proves the accepted half (and it is the better test anyway: it also produces the
|
||||
real `ip`).
|
||||
- **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned` and
|
||||
`ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always
|
||||
`AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`,
|
||||
wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world
|
||||
exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down
|
||||
first.
|
||||
649
link/v6.md
649
link/v6.md
@@ -1,649 +0,0 @@
|
||||
# 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
571
link/v7.md
@@ -1,571 +0,0 @@
|
||||
# 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.
|
||||
@@ -153,231 +153,3 @@ changes something — with deliberate exceptions, which are the leaks it was wri
|
||||
`/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw
|
||||
stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened
|
||||
`ownerAcct`. All are now stripped for every caller below admin.
|
||||
|
||||
## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11)
|
||||
|
||||
Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and
|
||||
it is the one an operator interacts with by name. `module-uo` declares **26 event triggers** and
|
||||
**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships
|
||||
**34 message bodies and 26 rules** through `api.registerEngagementSeeds`
|
||||
([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary —
|
||||
it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call.
|
||||
|
||||
**What a trigger is, and what it is not.** It is a *payload contract*: what a rule may fire on, what a
|
||||
template may interpolate, and — the part that is a security boundary — the widest audience an operator
|
||||
may ever give it. Declaring one sends nobody anything. An operator has to write a rule, and every rule
|
||||
core or this module seeds ships `enabled = 0`.
|
||||
|
||||
The declarations live in
|
||||
[`server/config/shardTriggers.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/shardTriggers.js);
|
||||
the wire-kind mapping that fires them is `server/utils/shardEngagement.js`, hung off `shardIngest`
|
||||
beside the SSE broadcast and the push tickle.
|
||||
|
||||
### 5.1 The catalogue, by ceiling
|
||||
|
||||
The **ceiling** is the widest audience a rule may ever be given for that trigger. It is checked when a
|
||||
rule is saved *and* again at send time, and it is ordered by **containment, not size** — a `staff`
|
||||
ceiling does not permit `owner`, because fewer people is not less exposure.
|
||||
|
||||
| Ceiling | Triggers | Why that ceiling |
|
||||
| --- | --- | --- |
|
||||
| `owner` | `uo.house.idoc_warning`, `uo.house.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All twelve resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify |
|
||||
| `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express |
|
||||
| `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want |
|
||||
| `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written for: under a flat "fewer people is narrower" ordering, a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected |
|
||||
| `admin` | `uo.audit.staff_action`, `uo.economy.milestone`, `uo.world.saved` | `staff` means admin, editor **and** moderator, so a digest of what staff did in game must not ceiling there. `admin` was added to the lattice for these three (MODULE_API 1.8.0) |
|
||||
|
||||
A `staff`- or `admin`-ceilinged id **does not appear by name in a player's preferences catalogue** —
|
||||
core filters the catalog on the ceiling, so a control that could do nothing is never offered and the
|
||||
event's existence is not disclosed.
|
||||
|
||||
### 5.2 Four rows that are deliberately absent
|
||||
|
||||
[`ENGAGEMENT.md`](../../website/ENGAGEMENT.md) §8.6 catalogues the candidate events and Phase 11 commits
|
||||
to shipping every one of them, so a row that does *not* ship needs a recorded reason. There are four:
|
||||
|
||||
| Not shipped | Reason |
|
||||
| --- | --- |
|
||||
| `uo.market.item_listed` | A saved **search**, not a trigger — its audience is "users whose stored query matches this listing", and no per-user query store exists. Its own workstream |
|
||||
| `uo.guild.joined` | Core's `team.member.joined` already fires for it: a UO guild **is** a Team and this module is the deployment's Team provider, so the roster reconcile emits on every join. A second trigger is two mails for one event |
|
||||
| `uo.link.requested` | No addressable recipient **by construction** — the account is not yet linked, which is the point of the event — and a ~5-minute `ttlSec` no channel can beat |
|
||||
| `uo.points.rank_changed`'s personal half | `points.board`'s `top[]` names a mobile **serial** and `shard_account_links` is keyed by **account**. The board-change feed ships; "you were pushed out" would reach some players and silently not others |
|
||||
|
||||
### 5.3 Two triggers that need a running patch tier or a v5 overlay
|
||||
|
||||
- **`uo.vendor.sale` requires the opt-in ServUO patch tier.** `vendor.sale` is emitted by a
|
||||
`PlayerVendorSale` EventSink that lives in `servuo-plugins/patches/`, not in `overlay/`. A shard that
|
||||
declined the tier emits the kind never, so a rule on it is **silently dormant rather than broken** —
|
||||
which is why the declaration's own operator-facing description says so.
|
||||
- **`uo.house.idoc_warning`'s schedule and `uo.vendor.expiring` need protocol 5.** Both read fields the
|
||||
v5 overlay added ([`link/v5.md`](../../link/v5.md)). The warning still fires on a v4 shard, simply
|
||||
without `nextStage` / `estimatedCollapse`; `uo.vendor.expiring` needs the `fees` block and does not
|
||||
fire at all without it. **An absent `estimatedCollapse` means "not knowable", never "not yet read"**
|
||||
— under dynamic decay ServUO draws each stage's duration at random, so the mapper passes the absence
|
||||
through rather than computing a guess the shard refused to publish.
|
||||
|
||||
### 5.4 Three things a rule cannot express, done in the mapper instead
|
||||
|
||||
Most rows are a field mapping. Three are not, and each is in the mapper rather than in a rule condition
|
||||
because `conditions.js` compares a declared variable against a **literal** — no arithmetic, no relative
|
||||
time, no previous value.
|
||||
|
||||
- **Transitions.** `champ.update` and `city.update` are full-state upserts re-emitted on any change, so
|
||||
without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A
|
||||
**first** sighting is never a transition.
|
||||
- **Thresholds.** `uo.vendor.expiring` fires on the crossing into a 48-hour window and not on every
|
||||
sweep frame (a shop is re-emitted whenever anyone reprices an item); a deposit that leaves the window
|
||||
re-arms it. `uo.economy.milestone` crosses a gold or account line, in either direction, never on
|
||||
first sight. Both declare an int (`hoursRemaining`, `value`) so an operator can still narrow with
|
||||
"is at most".
|
||||
- **`uo.server.up` / `down` is the cooldown table's stress test.** `server.hello` arrives on every
|
||||
*sidecar* reconnect, not only a shard restart, so the tracker suppresses a hello while the shard is
|
||||
already believed up — and the seeded rule carries a hard cooldown for a shard genuinely flapping.
|
||||
|
||||
### 5.5 The three audiences
|
||||
|
||||
Named sets of **people** an operator points a rule at or composes into a saved segment with and/or/not.
|
||||
A different mechanism from the `members` audience the guild triggers use: a registered audience answers
|
||||
the same question every time it is asked, which is what makes it storable.
|
||||
|
||||
| Audience | Params | Ceiling | Resolves to |
|
||||
| --- | --- | --- | --- |
|
||||
| `uo.guild.members` | `guildId` (int) | `members` | Everyone with a linked game account on that guild's roster |
|
||||
| `uo.governors` | — | `members` | Everyone with a linked account holding a city governorship |
|
||||
| `uo.linked.accounts` | — | `members` | Every website user with at least one linked game account — and, composed under `not`, the audience for the message asking the rest to link one |
|
||||
|
||||
Each resolver returns **user ids and nothing else** — never an address, a channel or a template — and
|
||||
each fails to the **empty set** rather than throwing, because an audience that cannot resolve is a rule
|
||||
that reaches nobody rather than one that breaks the engine.
|
||||
|
||||
### 5.6 Where the ordering matters
|
||||
|
||||
The engagement fan-out runs **before** `shardIngest` applies the frame's state change, and that is
|
||||
load-bearing. Three mappings read a row the state write is about to delete or replace:
|
||||
`account.unlinked` drops the `shard_account_links` row that names the one person who needs to be told;
|
||||
`house.remove` drops the house whose stored `ownerAcct` is the only place a collapsed house's owner
|
||||
appears (the frame carries a serial alone); and `guild.leave` / `guild.remove` need the roster and
|
||||
board mirrors to name who left and which guild it was. Resolving afterwards finds nobody, every time.
|
||||
|
||||
### 5.7 The shipped bodies (Phase 11b)
|
||||
|
||||
Declaring a trigger says what an event IS. It says nothing about what the message reads like, and
|
||||
until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core
|
||||
files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })`
|
||||
([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its
|
||||
first caller, with **34 bodies and 26 rules** in
|
||||
[`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js).
|
||||
|
||||
**Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision
|
||||
(ENGAGEMENT.md Phase 11b, decision 8) was a sender per family rather than one voice across all of
|
||||
them: a shard where Lord Blackthorn writes to you personally about a champion spawn is a shard where
|
||||
the letter about your governorship means nothing.
|
||||
|
||||
| Sender | Families |
|
||||
| --- | --- |
|
||||
| **Lord Blackthorn's court** | the governorship, the elections — the crown's business and nothing else |
|
||||
| **the Office of Deeds** | houses |
|
||||
| **the Merchants' Guild** | vendors |
|
||||
| **a guild herald** | guild departures and dissolutions |
|
||||
| **the town crier** | champion spawns |
|
||||
| **a guildmaster** | skills, quests |
|
||||
| **the Chronicler of the Dead** | deaths and murders |
|
||||
| **the keeper of the rolls** | leaderboards |
|
||||
|
||||
Each ships an `email` body and an `inapp` body **in the same voice** — one rule fires on both at once,
|
||||
and a player who reads the inbox item and then the mail must not meet two different narrators. The
|
||||
**digest** stays core's generic `notify.digest`: a day of events rolled into a list is not a letter
|
||||
from anybody.
|
||||
|
||||
**Nine stay plain, and the line is where fiction costs something real** (decision 9). Both
|
||||
account-security triggers, `uo.server.up`/`down`, and the five staff- and admin-ceilinged ones point
|
||||
at core's `notify.event` / `inapp.event` and author nothing — which is also §4.6.1 property 1 being
|
||||
exercised at scale. A failed-login notice written as *"a stranger sought entry to thy account"* is
|
||||
indistinguishable in register from the phishing mail it warns about, and a moderator reading
|
||||
`uo.cheat.detected` at two in the morning wants a name, a rule and a timestamp rather than a scroll.
|
||||
|
||||
**An operator whose shard is not Blackthorn's Britannia edits these rows.** The template editor is
|
||||
where, and `customized = 1` then protects the edit from every later seed — the bodies are defaults,
|
||||
not fixtures.
|
||||
|
||||
Two mechanical notes that will bite whoever adds the twenty-sixth trigger:
|
||||
|
||||
- **All 26 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended
|
||||
to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must
|
||||
reach existing deployments takes a new group key.
|
||||
- **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger
|
||||
id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is
|
||||
keyed `uo.champ.boss-up`. Registration refuses the mistake at boot.
|
||||
|
||||
### 5.7a `uo.house.refreshed` — the trigger that exists to cancel one
|
||||
|
||||
Added by the live walk (ENGAGEMENT.md Phase 11b, decision 11), and it is the only trigger in this
|
||||
module whose primary job is not to say something.
|
||||
|
||||
`uo.house.idoc_warning`'s seeded rule carries `delay_seconds: 900` so that a player who repairs the
|
||||
house inside the quarter-hour is never told it is in peril. That is only true if something CANCELS
|
||||
the pending row, and until this trigger existed nothing could: `cancel_on` named `uo.house.collapsed`
|
||||
— the outcome where the warning is pointless — and the mapper returned early on every transition that
|
||||
was not a late decay stage, so a refresh reached the engine as silence. The wire had carried the
|
||||
transition all along.
|
||||
|
||||
```
|
||||
house.decay Greatly -> Ageless (the owner logged back in)
|
||||
-> uo.house.refreshed (owner-audienced, subject = the house serial)
|
||||
-> cancels every scheduled engagement_outbox row for
|
||||
(the warning's rule, that house, that owner)
|
||||
-> and, if the operator enabled its own rule, sends the Office of Deeds'
|
||||
one non-warning letter
|
||||
```
|
||||
|
||||
Three things about it are load-bearing:
|
||||
|
||||
- **Its `subjectKey` is `houseSerial`, the same as the warning's.** `outboxDb.cancel` matches on
|
||||
`(rule, subject_key)`, so a refresh carrying any other subject would cancel nothing at all.
|
||||
- **It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case.** A condemned house
|
||||
cannot be refreshed — `BaseHouse.RefreshDecay()` refuses `DecayType.Condemned` outright — so the
|
||||
rescue is the owner logging in. Their newest house then becomes `AutoRefresh` and reads `Ageless`;
|
||||
an older one becomes `ManualRefresh` and reads `LikeNew`. Reading only the second misses most
|
||||
rescues.
|
||||
- **The cancellation does not depend on its own rule.** `cancel_on` is read off the WARNING's rule,
|
||||
so an operator who wants the cancellation without the reassurance letter simply leaves the new rule
|
||||
disabled — which, every seeded rule shipping disabled, is what a fresh install already does.
|
||||
|
||||
### 5.7b Every link a body offers comes from `config/clientPaths.js`
|
||||
|
||||
A notification's call-to-action is a path into this module's own SPA routes, and there is exactly one
|
||||
place that knows them. The live walk found every one of them wrong, in two independent ways:
|
||||
|
||||
- the declared `example`s read `/shard/…`, taken from `module.json`'s `mounts` — but
|
||||
`registry.registerRoutes` prefixes a module's client routes with its **ID** and nothing else, so
|
||||
the real paths are `/uo/houses`, `/uo/market`, `/player/uo/characters`. Every example was a 404,
|
||||
and an example is what the template editor previews and test-sends with;
|
||||
- and no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href
|
||||
and dropped out of the text part entirely.
|
||||
|
||||
`clientPaths.js` is now the single source for both the declarations and the bodies. `client/src/entry.jsx`'s
|
||||
own `registerNav` is the cross-check: the hrefs it hands the sidebar are these, and if the two ever
|
||||
disagree the sidebar is right.
|
||||
|
||||
### 5.8 The presentational fragments, and why they exist
|
||||
|
||||
A template has **no conditionals**, by design, and an unset optional interpolates to the **empty
|
||||
string**. That is right for a structural body and wrong for a sentence: *"Be it known that ,
|
||||
recorded to thy name, is this day found ."* So the ternary stays in `shardEngagement.js` and its
|
||||
result arrives as a declared optional — Phase 5a's `forWhom` precedent. Two shapes, and the `example`
|
||||
on each declaration shows which it is:
|
||||
|
||||
- a **LABEL** always has a value and carries a sentence's spine. `houseLabel` is the name and region,
|
||||
falling back to the seal number, because a warning has to name something the owner can act on.
|
||||
- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so `{{slainBy}}.` closes as
|
||||
*"has fallen."* either way.
|
||||
|
||||
They are declared `required: false` deliberately: a required variable missing **refuses the emit**,
|
||||
and a dropped notification is worse than a cosmetic hole. Nothing at runtime therefore notices a
|
||||
mapper that forgot one, so `server/test/engagementSeeds.test.js` asserts every label is supplied on
|
||||
every path that emits its trigger.
|
||||
|
||||
A whole **detail line** works the same way one level up (`ledgerLine`, `whereLine`): four optional
|
||||
numbers assembled into a sentence by the mapper, and absent entirely when the frame carried none of
|
||||
them — the same argument `place()` makes for coordinates. A pre-v5 vendor frame otherwise renders
|
||||
*"On hand: gold. Charged each period: gold."*
|
||||
|
||||
@@ -508,7 +508,7 @@ is only harmless while the default is off. Existing subscriptions are carried ac
|
||||
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
|
||||
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
|
||||
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
|
||||
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5. Every key is one of the rule's `channels` **plus one that is not a channel at all**: `digest` names the body the digest worker renders for a rule whose email channel an individual set to digest mode, so it belongs to a mode and can never appear in `channels`. The rules validator rejected it until website#181, which made every rule shipping one — core's own Team and news rules included — unsaveable from the Rules screen |
|
||||
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
|
||||
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
|
||||
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
|
||||
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
|
||||
@@ -545,35 +545,21 @@ The ceiling is a **stored column rather than a runtime computation** so an audit
|
||||
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
|
||||
ceiling cannot retroactively widen a segment saved under the old one.
|
||||
|
||||
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
|
||||
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
|
||||
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
|
||||
| last_fired_at | DATETIME NOT NULL | |
|
||||
| fire_count | INT NOT NULL DEFAULT 1 | |
|
||||
|
||||
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune.
|
||||
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
|
||||
|
||||
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
|
||||
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
|
||||
(rule, user) alone silently drops three of them.
|
||||
|
||||
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
|
||||
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
|
||||
without the channel in the key the first channel of a rule claimed the cooldown and every later one
|
||||
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
|
||||
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
|
||||
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
|
||||
|
||||
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
|
||||
key change** — replaying `schema.sql` on every boot would fail after the first run without the
|
||||
`information_schema` guard that reads whether the key already carries the column. Rows written before
|
||||
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
|
||||
let a storm through the window.
|
||||
|
||||
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
|
||||
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
|
||||
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
|
||||
@@ -588,15 +574,14 @@ cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
|
||||
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
|
||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
|
||||
|
||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
|
||||
|
||||
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
|
||||
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
||||
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
||||
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
|
||||
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
|
||||
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures; `created_at` is what the retention sweep measures |
|
||||
| attempts / last_error / sent_at | | |
|
||||
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
|
||||
|
||||
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
|
||||
@@ -638,35 +623,6 @@ a rule's budget and mute it.
|
||||
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
|
||||
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
||||
counts only.
|
||||
Phase 9 gave two of those statuses their first writers. `suppressed` means the address was on the
|
||||
suppression list and **no transport call was made**; `bounced` means one was, and the mailbox does
|
||||
not exist. `complained` still has none — it needs a provider feedback loop, which SMTP has not got.
|
||||
|
||||
### engagement_suppressions — addresses we have stopped mailing (engagement phase 9)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| address_hash | CHAR(64) NOT NULL PK | sha256 of the **lower-cased, trimmed** address |
|
||||
| address_masked | VARCHAR(190) NULL | `d***@example.com`. Phase 9's one addition to the planned DDL |
|
||||
| channel | VARCHAR(32) NOT NULL DEFAULT 'email' | |
|
||||
| reason | ENUM('bounce','complaint','manual','unverified') | |
|
||||
| detail | VARCHAR(500) NULL | e.g. `hard bounce: 5.1.1` |
|
||||
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | the admin, for a manual row; **NULL for an automatic one**, which is what separates the two |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
`INDEX(created_at)`, `INDEX(reason, created_at)` — the screen's two orderings.
|
||||
|
||||
G16. **Keyed on the address, not the user**, and after Phase 1b made addresses unique that is a
|
||||
choice rather than a workaround: a bounce arrives as an address, it does not know which account was
|
||||
behind it, and it stays true after that account changed its address or was deleted.
|
||||
|
||||
Writes are `INSERT IGNORE`, so **the first reason an address was suppressed is the one that
|
||||
survives** — an address that hard-bounced in March and was manually re-added in June still reads
|
||||
`bounce`, because that is the fact explaining why the mail stopped. An upsert would let the most
|
||||
recent write overwrite the diagnosis.
|
||||
|
||||
`address_masked` exists because a hash-only table cannot be operated; the reasoning and the routes
|
||||
are in §7's *Deliverability* subsection.
|
||||
|
||||
|
||||
### engagement_digest_state — how far each digest has got (engagement phase 6)
|
||||
| col | type | notes |
|
||||
@@ -763,58 +719,6 @@ drops unread items is one whose badge means nothing. The horizon is `settings.us
|
||||
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
|
||||
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
|
||||
worker that file is modelled on.
|
||||
### Engagement retention — three sweeps and one recorded refusal (engagement phase 14)
|
||||
|
||||
Four engagement tables grow, and until Phase 14 nothing deleted from any of them.
|
||||
`utils/engagementRetentionPrune.js` is one nightly worker over three of them —
|
||||
`setInterval` + `unref` + `stop()`, wired into `server.js` beside `teamActivityPrune` and
|
||||
`inboxPrune`, batched 1000 × 50 per table, each table's failure caught on its own so a lock
|
||||
timeout on one does not leave the other two unbounded.
|
||||
|
||||
| table | horizon | setting | what is eligible |
|
||||
|---|---|---|---|
|
||||
| `engagement_sends` | 180 days | `engagement_sends_retain_days` (7–3650) | every row; they are all terminal |
|
||||
| `engagement_cooldowns` | 30 days | `engagement_cooldowns_retain_days` (2–3650) | every row, by `last_fired_at` |
|
||||
| `engagement_outbox` | 30 days | `engagement_outbox_retain_days` (2–3650) | **terminal rows only** — `sent`, `failed`, `cancelled`, `suppressed` — by `created_at` |
|
||||
| `engagement_suppressions` | **never** | — | nothing. See below |
|
||||
|
||||
**The outbox sweep is terminal-only, and that is a correctness rule, not a preference.** A
|
||||
`scheduled` row is a send this deployment still intends to make — `delay_seconds` can legitimately
|
||||
put one a day out — and a `sending` row may be a worker mid-flight. A sweep by age alone would
|
||||
cancel sends nobody cancelled, and the only symptom would be mail that never arrived.
|
||||
|
||||
**The floors are not UI niceties.** Below two days, a pruned cooldown row makes the next fire a
|
||||
FIRST fire — the rule sends twice; `MAX_COOLDOWN_SECONDS` is a validated 86 400, so two days is the
|
||||
smallest provably-safe value against any rule that can be saved. The send log's floor is a week
|
||||
because `engagement_sends` has two live readers: the per-rule hourly ceiling counts it
|
||||
(§7.1 Q3), and Admin → Engagement → Send Log is the operator's only answer to "was this person
|
||||
told".
|
||||
|
||||
**The cooldown horizon is checked, not assumed.** `engagementRules.db.maxEnabledCooldownSeconds()`
|
||||
(enabled rules only — a disabled rule writes no cooldown row) is compared against the horizon on
|
||||
every read of the policy and on every sweep. A horizon that does not clear it produces a warning on
|
||||
the screen and in the log, **and the sweep runs anyway**: refusing to prune would trade a bounded,
|
||||
describable fault for the unbounded one this phase exists to end.
|
||||
|
||||
**`engagement_suppressions` does not expire, and that is the recorded decision** (org lead,
|
||||
2026-09-01). A suppression is a standing decision, not a record of something that happened; ageing
|
||||
out a hard bounce re-mails an address that already bounced, which is how a sender loses a domain's
|
||||
reputation. The way out stays deliberate — see the per-row lift in the route table.
|
||||
|
||||
**Retention has a screen**, unlike `team_activity` and `user_notifications`, whose horizons are
|
||||
invisible settings rows. The send-log horizon changes what an operator-facing page is *able to show*,
|
||||
so it has to be visible and settable; having made one visible, hiding the other two would split one
|
||||
question ("what does this deployment keep") across two places. `EngagementSendLog.jsx` reads the
|
||||
policy and prints *"entries older than N days are removed automatically"* beneath its pager, so the
|
||||
total it shows stops being quietly wrong.
|
||||
|
||||
**One shipped defect this phase had to fix to be a bound at all.** `outboxDb.reclaimStale` returned
|
||||
every stale `sending` row to `scheduled`, and `MAX_ATTEMPTS` is consulted only on a graceful `retry`
|
||||
outcome — so a send that killed the process mid-flight cycled sending → scheduled → sending forever,
|
||||
never reached a terminal status, and was therefore never eligible for any sweep. It now fails a row
|
||||
that has burned its attempts **before** reclaiming the rest; the order is the fix, and reversing it
|
||||
hands the exhausted row straight back to `findDue`.
|
||||
|
||||
|
||||
### The two block registries — pages and mail (engagement phase 5a)
|
||||
|
||||
@@ -1117,190 +1021,6 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
|
||||
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
||||
|
||||
---
|
||||
### 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
|
||||
|
||||
@@ -1467,27 +1187,8 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
|
||||
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
|
||||
**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
|
||||
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and — from
|
||||
**ENGAGEMENT.md Phase 11** — the **engagement engine** for `news.post`.
|
||||
|
||||
> **The news publish path changed in Phase 11 and it is an operator-visible change.** Publishing a
|
||||
> news post used to call `pushDispatch.publish('news.post', …)` directly from
|
||||
> `admin.controller.js`'s `announceIfNewlyPublished`; it now calls
|
||||
> `utils/newsNotify.emitNewsPost`, which emits core's declared `news.post` trigger and lets the engine
|
||||
> decide. Push therefore rides a **rule** like every other channel, and core seeds that rule
|
||||
> `enabled = 0` beside the four Team ones — so **news push stops on upgrade** until an operator enables
|
||||
> it in Admin → Engagement → Rules, where a banner says so. The `news.post` seed carries its own
|
||||
> one-shot settings key (`engagement_news_rule_seeded`) rather than joining the Team group's, because
|
||||
> the Team key is already stamped on exactly the deployments this affects.
|
||||
>
|
||||
> **The other two things a publish fires are untouched.** `announceJobs.enqueueIfNeeded` (a one-shot
|
||||
> delivery to a channel of the deployment — the in-game town crier, Discord #news, with retry) and
|
||||
> `registries.dispatchPostHook('onSaved')` (idempotent state mirroring, which also runs on delete) are
|
||||
> different kinds of thing and still fire exactly as they did. The emit is gated on the same
|
||||
> `enqueueIfNeeded` job id the push was gated on — the single "newly published news" transition
|
||||
> signal — so an edit or a re-publish still does not re-fire.
|
||||
|
||||
The catalog is assembled at boot by
|
||||
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
|
||||
create/publish-post path for `news.post`. The catalog is assembled at boot by
|
||||
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
|
||||
each installed module's. The seven shard streams and their event→stream mapping left with
|
||||
@@ -1696,34 +1397,8 @@ file a route sits in — that is the property the route manifest freezes.
|
||||
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
|
||||
| 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 | `/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 |
|
||||
| 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 |
|
||||
| — | `/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`.
|
||||
|
||||
@@ -1926,74 +1601,6 @@ credentials.
|
||||
`transport='smtp'` with no credentials, so every sink above politely does nothing and **mail simply
|
||||
stops**. The admin dashboard warns whenever the deprecated Gmail token is present and no replacement
|
||||
credential is; see [`UPGRADE_NOTES.md`](UPGRADE_NOTES.md).
|
||||
### Deliverability: suppression, bounces and the verification gate *(engagement phase 9)*
|
||||
|
||||
Engagement Phase 9 ([`ENGAGEMENT.md`](ENGAGEMENT.md) Phase 9). Two mechanisms decide that a person
|
||||
who is *in* a rule's audience does not get the mail, and they are deliberately at different points
|
||||
in the pipeline.
|
||||
|
||||
**`engagement_suppressions` — checked at DELIVERY.** Keyed on `address_hash` (sha256 of the
|
||||
lower-cased address), because a bounce arrives as an address and stays true after the account behind
|
||||
it changed its address or was deleted. An outbox row can sit through a rule's `delay_seconds` grace
|
||||
window and an address can bounce inside it, so the only correct check is the one taken immediately
|
||||
before the transport call — which is also what produces the `status='suppressed'` row in
|
||||
`engagement_sends` with no transport call at all.
|
||||
|
||||
**The verification gate — applied at ENQUEUE.** With the `email_verification_required` setting on
|
||||
(seeded in Phase 1b: `on` for a fresh install, `off` for an upgrade), an unverified address is
|
||||
excluded before an outbox row is written. It hangs off a channel's optional **`eligible(userIds)`**
|
||||
registration rather than living in the engine: being unverified is an *email* fact, and a rule
|
||||
spanning email and in-app must still reach that person's inbox. Only `email` declares one. The
|
||||
excluded count comes back so the admin reach preview reports it instead of quietly promising a
|
||||
number the engine will not deliver.
|
||||
|
||||
**Scope: engagement rules only.** Password resets, invites, verification mails and the contact form
|
||||
still attempt to a suppressed or unverified address. This is the posture `passwordReset.controller.js`
|
||||
already took — user-initiated mail must not be blocked by a background system's opinion, and one
|
||||
reset to a dead mailbox is not a reputation problem, whereas a rule mailing thousands of people
|
||||
weekly is.
|
||||
|
||||
**What may write a `bounce` row is narrower than "the send failed".** `src/engagement/bounceClassify.js`
|
||||
is the only judge, and it is deliberately **not** `mailer.PERMANENT_CODES` — that set answers "is
|
||||
retrying pointless?" and contains `EAUTH` and `554`, so reusing it would mean one stale SMTP password
|
||||
suppressing every address the worker touched, silently. The classifier reads the **RFC 3463 enhanced
|
||||
status** first (`5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` suppress; `5.3.x`, `5.5.x` and
|
||||
`5.7.x` never do, being about the server or our standing with it), and falls back — only for `550`,
|
||||
`551` and `553`, and only past a veto list — to a phrase match. **Anything it is unsure about is not
|
||||
suppressed:** a false negative costs one retry next month, a false positive costs a person who
|
||||
silently stops hearing from the deployment.
|
||||
|
||||
SMTP has no *asynchronous* bounce or complaint feed — that is where an API-based provider would earn
|
||||
its place — but a single-recipient send refused at `RCPT TO` throws synchronously with the reply
|
||||
code intact, which is the highest-value signal there is and is what this reads. `sendNotification`
|
||||
therefore returns an `smtp: { code, responseCode, response }` triple alongside its classification;
|
||||
`retry` and `detail` cannot answer "was this the recipient's fault", since `550 5.1.1` and
|
||||
`550 5.7.1` are an identical `retry: false`.
|
||||
|
||||
**Statuses.** `engagement_sends.status` gains two real writers: `suppressed` (declined to try) and
|
||||
`bounced` (tried, the mailbox does not exist). `engagement_outbox.status` records `bounced` as
|
||||
`failed` — its ENUM has no such value and, from the queue's point of view, a bounced row is one that
|
||||
finished unsuccessfully. `complained` still has no writer: it needs a provider feedback loop.
|
||||
|
||||
**Routes** (all `adminOnly`, under `/api/v1/admin/engagement`):
|
||||
|
||||
| Route | Notes |
|
||||
| --- | --- |
|
||||
| `GET /suppressions` | Paged, filterable by `reason` / `channel` / `search`, plus unfiltered `byReason` totals. **Returns `address_hash` from Phase 14 on**, reversing Phase 9's decision to strip it: without a handle the only way out of the list was a `window.prompt` asking the operator to retype an address the screen has never shown them. The trade — this route is admin-only, and an admin can already suppress and unsuppress any address they can name, so the hash grants no capability they lack. `GET /sends` still strips its own hash: nothing there needs to act on a row |
|
||||
| `POST /suppressions` | `reason` is forced to `manual` — an admin typing an address is not evidence of a bounce. An address already listed answers 200 with `created: false`, not 409 |
|
||||
| `DELETE /suppressions` | The way out for an address the operator can type. It goes in the **body**, not the path: a path parameter lands in the access log, the browser history and every proxy in front of the deployment |
|
||||
| `DELETE /suppressions/by-hash/:hash` | The per-row Lift button (Phase 14). Same effect, different input — the operator is looking at a mask and knows only the row's handle. The handle **is** safe in the path where an address is not: a sha256 already served only to an admin session leaks nothing further by being logged. Shape-validated to 64 hex characters before it reaches a WHERE clause; `404` rather than `200` when nothing matched, so a stale screen says so instead of claiming success |
|
||||
| `GET · PUT /retention` | The three sweep horizons in days, with the bounds each is validated against and a `warnings` array carrying the one check that is not a static bound — a cooldown horizon shorter than the longest cooldown on an ENABLED rule. `engagement_suppressions` is deliberately absent: it does not expire. The `PUT` is **sparse** (saving one select cannot clobber another admin's concurrent change) and **refuses out-of-range rather than clamping**, because storing something other than what was typed would leave the screen describing a policy the deployment is not running |
|
||||
|
||||
**Neither route ever returns `address_hash`**, the same rule `GET /sends` follows: a sha256 of every
|
||||
address on the deployment, handed to a browser, is an offline dictionary attack. What the list
|
||||
returns is `address_masked` — `d***@example.com` — which Phase 9 added to §4.5's DDL because a
|
||||
hash-only table cannot be operated: an operator has to be able to see a whole domain refusing mail
|
||||
and to let back in somebody who fixed their mailbox. The domain survives intact for the first; the
|
||||
local part is destroyed rather than shortened, so the column can never be read back as an address
|
||||
book. The consequence is that **lifting a suppression needs the full address typed in** — the screen
|
||||
genuinely does not have it, which is the privacy design working rather than a rough edge.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2286
website/EVENTS.md
2286
website/EVENTS.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,394 +26,16 @@ 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.10.0'
|
||||
const MODULE_API_VERSION = '1.7.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.
|
||||
|
||||
It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what
|
||||
the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core
|
||||
files with core arrays in them and there was no registration call beside them, so a module's
|
||||
notification was core's generic `notify.event` body or nothing at all. That is tolerable for one
|
||||
trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that
|
||||
core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary
|
||||
is honoured deliberately rather than enforced mechanically).
|
||||
|
||||
```js
|
||||
api.registerEngagementSeeds({
|
||||
templates: [{
|
||||
key: 'uo.house.idoc-warning', // MUST be namespaced "<moduleId>."
|
||||
name: 'House — final decay warning',
|
||||
channel: 'email', // 'email' | 'inapp'
|
||||
subject: 'Thy house at {{region}} stands in peril',
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
triggerVersion: 2,
|
||||
seedVersion: 1,
|
||||
blocks: [ /* the same block objects the template editor writes */ ],
|
||||
}],
|
||||
ruleGroups: [{
|
||||
key: 'triggers-v1', // the one-shot guard's name — see below
|
||||
note: 'shown in the boot log when it inserts',
|
||||
rules: [{
|
||||
trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own
|
||||
name: 'House decay warning',
|
||||
audience: 'owner',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' },
|
||||
cooldown_seconds: 86400,
|
||||
max_sends_per_hour: 200,
|
||||
delay_seconds: 900, // optional
|
||||
cancel_on: ['uo.house.refreshed'], // optional
|
||||
conditions: null, // optional
|
||||
}],
|
||||
}],
|
||||
})
|
||||
```
|
||||
|
||||
**Callable once per module, and validate-then-commit like every other registration.** A module that
|
||||
got one of thirty templates wrong ships none of them and finds out at boot with the offending key
|
||||
named, rather than at send time with a half-seeded table.
|
||||
|
||||
**The two halves behave differently, and the asymmetry is the contract.**
|
||||
|
||||
- **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and
|
||||
`customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an
|
||||
operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module
|
||||
version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body
|
||||
changes; never for a comment.**
|
||||
- **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted
|
||||
and reset one they enabled, so each group carries its own settings guard
|
||||
(`engagement_module_rules_seeded:<owner>:<group>`). **A rule appended to an existing group therefore
|
||||
reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing
|
||||
deployments takes a **new group key**. The module names its groups, so the module makes that choice;
|
||||
make it knowingly.
|
||||
|
||||
**Three things a module may not do, each of which is a shipped mistake that would only surface as
|
||||
mail somebody received.**
|
||||
|
||||
1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is
|
||||
ignored rather than refused, because refusing would let a typo take a module offline at boot. This
|
||||
is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream.
|
||||
2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this
|
||||
body"*, which is true of a password reset and of nothing a module ships; setting it would take an
|
||||
operator's delete button away.
|
||||
3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this
|
||||
module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is
|
||||
§4.6.1 property 1 in force, and the right answer for any trigger whose message is structural).
|
||||
A template `key` must be namespaced `<moduleId>.`, because `engagement_templates.key` is UNIQUE
|
||||
across the table and an unprefixed `notify.event` from a module would collide with core's and win
|
||||
or lose on boot order.
|
||||
|
||||
**When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()`
|
||||
**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader —
|
||||
so at the moment core seeds its own templates, no module has registered anything. Module seeds are
|
||||
therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and
|
||||
**before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled**
|
||||
(or one that failed to load) is skipped rather than seeded, and a module that warms a cache in
|
||||
`onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every
|
||||
other step there — a body that would not seed costs the shipped default, never the boot.
|
||||
|
||||
**It is not a send path.** Everything on the object is data; nothing on it is a function and nothing
|
||||
on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is
|
||||
*about*, and core decides who is told, after the ceiling, the preferences, the suppression list and
|
||||
the verification gate.
|
||||
|
||||
**1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One
|
||||
addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored
|
||||
value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a
|
||||
trigger or an audience and a `staff`-ceilinged trigger accepts an `admin` audience as a narrowing.
|
||||
|
||||
It exists because the narrowest role-shaped value the lattice had was `staff`, which means **admin,
|
||||
editor AND moderator**. Phase 11's operator-facing triggers — a digest of what staff did in game, the
|
||||
economy thresholds, the world-save counts — are admin-audience everywhere they are described, and
|
||||
ceilinging them at `staff` would have let an operator save a rule that mails the staff audit digest to
|
||||
every moderator in it.
|
||||
|
||||
**What a module author has to know beyond the new name.** `admin` is the **only pair in the whole
|
||||
lattice with real containment** — every admin is staff, which is exactly what every other pair of
|
||||
branches lacks — so it is the only place `permits` is true between two values below `authenticated`.
|
||||
`permits('staff', 'admin')` holds; `permits('admin', 'staff')` does not, and neither direction holds
|
||||
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
|
||||
`api.registerEngagementSeeds({...})` (§2.4) ·
|
||||
`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) ·
|
||||
`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3).
|
||||
|
||||
**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked
|
||||
@@ -635,8 +257,8 @@ module-uo does not need is on the list.
|
||||
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
|
||||
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
|
||||
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
||||
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) |
|
||||
| `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) |
|
||||
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | a module's own event mapper (1.7.0) |
|
||||
| `ctx.inbox.push` | `(userId, item) => Promise<void>` — **throws until Phase 7** | the in-app channel | not yet (1.7.0) |
|
||||
|
||||
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
|
||||
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
|
||||
@@ -756,11 +378,6 @@ api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.
|
||||
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
||||
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 () => {})
|
||||
```
|
||||
@@ -1052,12 +669,10 @@ Six things about it are contract rather than implementation:
|
||||
because under one namespace they are the same ids.
|
||||
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
|
||||
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
|
||||
would silently widen a staff-only event. The seven values are `everyone`, `authenticated`,
|
||||
`subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by
|
||||
**containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is
|
||||
not less exposure. `staff` → `admin` is the single true refinement in the tree and the only pair
|
||||
below `authenticated` that `permits` accepts. A default `audience` wider than, or incomparable with,
|
||||
the ceiling is refused at registration.
|
||||
would silently widen a staff-only event. The six values are `everyone`, `authenticated`,
|
||||
`subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a
|
||||
`staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default
|
||||
`audience` wider than, or incomparable with, the ceiling is refused at registration.
|
||||
- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and
|
||||
test-sending a template possible without a live game event, which is the reason template systems go
|
||||
untested. A variable without one is refused.
|
||||
@@ -1110,111 +725,6 @@ Four things about it are contract rather than implementation:
|
||||
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
|
||||
predates them.
|
||||
|
||||
**`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message
|
||||
BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two
|
||||
declarations above: they say what an event IS and who it is about, and this says what the mail reads
|
||||
like. Callable once per module. The full shape, the three prohibitions and the boot-path placement
|
||||
are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here:
|
||||
|
||||
- **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under
|
||||
`seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not
|
||||
edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an
|
||||
operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh
|
||||
installs only** — that is not a limitation to work around, it is the guarantee; a rule that must
|
||||
reach existing deployments takes a new group key.
|
||||
- **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's
|
||||
mail on; installing a module never does.
|
||||
- **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at
|
||||
`notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a
|
||||
bespoke body when the message has something to say that the structural projection cannot; a trigger
|
||||
whose message is "this happened, here is the link" should not have one.
|
||||
- **`digest` is a slot, not a channel.** It names the body the digest worker renders for a rule whose
|
||||
email channel an individual set to digest mode, so it is legal in `template_keys` and never appears
|
||||
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
|
||||
@@ -1457,17 +967,6 @@ 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
|
||||
|
||||
@@ -137,7 +137,6 @@ website/
|
||||
│ │ │ └── registry.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── security/
|
||||
│ │ │ │ ├── EmailAddressPanel.jsx
|
||||
│ │ │ │ ├── RecoveryCodesDisplay.jsx
|
||||
│ │ │ │ ├── RecoveryCodesPanel.jsx
|
||||
│ │ │ │ ├── TrustedDevicesPanel.jsx
|
||||
@@ -148,7 +147,6 @@ website/
|
||||
│ │ │ ├── Modal.jsx
|
||||
│ │ │ ├── MoonDot.jsx
|
||||
│ │ │ ├── NavDropdown.jsx
|
||||
│ │ │ ├── NotificationBell.jsx
|
||||
│ │ │ ├── PageHeader.jsx
|
||||
│ │ │ ├── PageState.jsx
|
||||
│ │ │ ├── ProviderIcon.jsx
|
||||
@@ -162,18 +160,12 @@ website/
|
||||
│ │ ├── contexts/
|
||||
│ │ │ ├── AuthContext.jsx
|
||||
│ │ │ └── SiteContext.jsx
|
||||
│ │ ├── emailBlocks/
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── registry.js
|
||||
│ │ │ └── types.jsx
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── adminNav.js
|
||||
│ │ │ ├── engagementRules.js
|
||||
│ │ │ ├── format.js
|
||||
│ │ │ ├── heroLayout.js
|
||||
│ │ │ ├── moduleAdmin.js
|
||||
│ │ │ ├── navOverrides.js
|
||||
│ │ │ ├── notificationPaths.js
|
||||
│ │ │ ├── pageShell.js
|
||||
│ │ │ ├── settingsJson.js
|
||||
│ │ │ ├── teamActivity.js
|
||||
@@ -209,13 +201,6 @@ website/
|
||||
│ │ │ │ │ ├── Dashboard.jsx
|
||||
│ │ │ │ │ ├── DiscordBotAdmin.jsx
|
||||
│ │ │ │ │ ├── EmailDelivery.jsx
|
||||
│ │ │ │ │ ├── EngagementAudiences.jsx
|
||||
│ │ │ │ │ ├── EngagementRetention.jsx
|
||||
│ │ │ │ │ ├── EngagementRules.jsx
|
||||
│ │ │ │ │ ├── EngagementSendLog.jsx
|
||||
│ │ │ │ │ ├── EngagementSuppressions.jsx
|
||||
│ │ │ │ │ ├── EngagementTemplates.jsx
|
||||
│ │ │ │ │ ├── EngagementTriggers.jsx
|
||||
│ │ │ │ │ ├── HeroEditor.jsx
|
||||
│ │ │ │ │ ├── InvitesAdmin.jsx
|
||||
│ │ │ │ │ ├── Moderation.jsx
|
||||
@@ -246,15 +231,13 @@ website/
|
||||
│ │ │ │ ├── ForgotPassword.jsx
|
||||
│ │ │ │ ├── PlayerAccount.jsx
|
||||
│ │ │ │ ├── PlayerAppeals.jsx
|
||||
│ │ │ │ ├── PlayerInbox.jsx
|
||||
│ │ │ │ ├── PlayerLogin.jsx
|
||||
│ │ │ │ ├── PlayerNotifications.jsx
|
||||
│ │ │ │ ├── PlayerPortalLayout.jsx
|
||||
│ │ │ │ ├── PlayerRegister.jsx
|
||||
│ │ │ │ ├── PlayerShell.jsx
|
||||
│ │ │ │ ├── ResetPassword.jsx
|
||||
│ │ │ │ ├── Unsubscribe.jsx
|
||||
│ │ │ │ └── VerifyEmail.jsx
|
||||
│ │ │ │ └── Unsubscribe.jsx
|
||||
│ │ │ ├── public/
|
||||
│ │ │ │ ├── About.jsx
|
||||
│ │ │ │ ├── CmsPage.jsx
|
||||
@@ -277,8 +260,6 @@ website/
|
||||
│ ├── test/
|
||||
│ │ ├── adminNav.test.js
|
||||
│ │ ├── apiClient.test.js
|
||||
│ │ ├── emailTemplates.test.js
|
||||
│ │ ├── engagementRules.test.js
|
||||
│ │ ├── featureGate.test.js
|
||||
│ │ ├── format.test.js
|
||||
│ │ ├── heroLayout.test.js
|
||||
@@ -287,7 +268,6 @@ website/
|
||||
│ │ ├── moduleRegistry.test.js
|
||||
│ │ ├── moduleSlots.test.js
|
||||
│ │ ├── navOverrides.test.js
|
||||
│ │ ├── notificationPaths.test.js
|
||||
│ │ ├── pageShell.test.js
|
||||
│ │ ├── settingsJson.test.js
|
||||
│ │ ├── teamActivity.test.js
|
||||
@@ -312,14 +292,12 @@ website/
|
||||
│ │ ├── sso-bridge-smoketest.js
|
||||
│ │ └── stub-idp.js
|
||||
│ ├── checkModuleIdentifiers.js
|
||||
│ ├── checkNoExternalHosts.js
|
||||
│ └── sonar-test-reporter.mjs
|
||||
├── server/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.sql
|
||||
│ │ └── seed.js
|
||||
│ ├── scripts/
|
||||
│ │ ├── engagementManifest.js
|
||||
│ │ └── routeManifest.js
|
||||
│ ├── src/
|
||||
│ │ ├── auth/
|
||||
@@ -353,46 +331,9 @@ website/
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── brand.js
|
||||
│ │ │ ├── coreStreams.js
|
||||
│ │ │ ├── coreTriggers.js
|
||||
│ │ │ ├── csp.js
|
||||
│ │ │ ├── themePresets.js
|
||||
│ │ │ └── version.js
|
||||
│ │ ├── emailBlocks/
|
||||
│ │ │ ├── types/
|
||||
│ │ │ │ ├── button.js
|
||||
│ │ │ │ ├── divider.js
|
||||
│ │ │ │ ├── heading.js
|
||||
│ │ │ │ ├── image.js
|
||||
│ │ │ │ ├── itemList.js
|
||||
│ │ │ │ └── text.js
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── interpolate.js
|
||||
│ │ │ ├── registry.js
|
||||
│ │ │ ├── render.js
|
||||
│ │ │ └── variables.js
|
||||
│ │ ├── engagement/
|
||||
│ │ │ ├── transports/
|
||||
│ │ │ │ ├── index.js
|
||||
│ │ │ │ └── smtp.js
|
||||
│ │ │ ├── audiences.js
|
||||
│ │ │ ├── bounceClassify.js
|
||||
│ │ │ ├── channels.js
|
||||
│ │ │ ├── conditions.js
|
||||
│ │ │ ├── coreChannels.js
|
||||
│ │ │ ├── coreRules.js
|
||||
│ │ │ ├── coreScopePrefs.js
|
||||
│ │ │ ├── emailChannel.js
|
||||
│ │ │ ├── engine.js
|
||||
│ │ │ ├── inappChannel.js
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── moduleSeeds.js
|
||||
│ │ │ ├── projection.js
|
||||
│ │ │ ├── pushChannel.js
|
||||
│ │ │ ├── scopedPrefs.js
|
||||
│ │ │ ├── segments.js
|
||||
│ │ │ ├── suppressions.js
|
||||
│ │ │ ├── templates.js
|
||||
│ │ │ └── templateSeeds.js
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── botScore.js
|
||||
│ │ │ ├── loginProtection.js
|
||||
@@ -422,26 +363,6 @@ website/
|
||||
│ │ │ ├── emailConfig/
|
||||
│ │ │ │ ├── emailConfig.db.js
|
||||
│ │ │ │ └── emailConfig.model.js
|
||||
│ │ │ ├── emailDedupe/
|
||||
│ │ │ │ ├── emailDedupe.db.js
|
||||
│ │ │ │ └── emailDedupe.model.js
|
||||
│ │ │ ├── emailVerifications/
|
||||
│ │ │ │ ├── emailVerifications.db.js
|
||||
│ │ │ │ └── emailVerifications.model.js
|
||||
│ │ │ ├── engagement/
|
||||
│ │ │ │ ├── engagementCooldowns.db.js
|
||||
│ │ │ │ ├── engagementDigest.db.js
|
||||
│ │ │ │ ├── engagementOutbox.db.js
|
||||
│ │ │ │ ├── engagementRecipients.db.js
|
||||
│ │ │ │ ├── engagementRetention.model.js
|
||||
│ │ │ │ ├── engagementRules.db.js
|
||||
│ │ │ │ ├── engagementRules.model.js
|
||||
│ │ │ │ ├── engagementSegments.db.js
|
||||
│ │ │ │ ├── engagementSegments.model.js
|
||||
│ │ │ │ ├── engagementSends.db.js
|
||||
│ │ │ │ ├── engagementSuppressions.db.js
|
||||
│ │ │ │ ├── engagementTemplates.db.js
|
||||
│ │ │ │ └── engagementTemplates.model.js
|
||||
│ │ │ ├── invites/
|
||||
│ │ │ │ ├── invites.db.js
|
||||
│ │ │ │ └── invites.model.js
|
||||
@@ -461,9 +382,6 @@ website/
|
||||
│ │ │ ├── modules/
|
||||
│ │ │ │ ├── modules.db.js
|
||||
│ │ │ │ └── modules.model.js
|
||||
│ │ │ ├── notificationChannelPrefs/
|
||||
│ │ │ │ ├── notificationChannelPrefs.db.js
|
||||
│ │ │ │ └── notificationChannelPrefs.model.js
|
||||
│ │ │ ├── notificationSubs/
|
||||
│ │ │ │ ├── notificationSubs.db.js
|
||||
│ │ │ │ └── notificationSubs.model.js
|
||||
@@ -522,8 +440,6 @@ website/
|
||||
│ │ │ ├── userIdentities/
|
||||
│ │ │ │ ├── userIdentities.db.js
|
||||
│ │ │ │ └── userIdentities.model.js
|
||||
│ │ │ ├── userNotifications/
|
||||
│ │ │ │ └── userNotifications.db.js
|
||||
│ │ │ ├── users/
|
||||
│ │ │ │ ├── users.db.js
|
||||
│ │ │ │ └── users.model.js
|
||||
@@ -534,7 +450,6 @@ website/
|
||||
│ │ │ └── singletonConfigDb.js
|
||||
│ │ ├── modules/
|
||||
│ │ │ ├── archive.js
|
||||
│ │ │ ├── ceilings.js
|
||||
│ │ │ ├── declared.js
|
||||
│ │ │ ├── install.js
|
||||
│ │ │ ├── lifecycle.js
|
||||
@@ -546,6 +461,8 @@ website/
|
||||
│ │ ├── router/
|
||||
│ │ │ ├── v1/
|
||||
│ │ │ │ ├── admin/
|
||||
│ │ │ │ │ ├── account.controller.js
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── activity.router.js
|
||||
│ │ │ │ │ ├── admin.controller.js
|
||||
│ │ │ │ │ ├── authProviders.controller.js
|
||||
@@ -557,8 +474,6 @@ website/
|
||||
│ │ │ │ │ ├── discordBot.router.js
|
||||
│ │ │ │ │ ├── email.router.js
|
||||
│ │ │ │ │ ├── emailConfig.controller.js
|
||||
│ │ │ │ │ ├── engagement.controller.js
|
||||
│ │ │ │ │ ├── engagement.router.js
|
||||
│ │ │ │ │ ├── imageUpload.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── invites.controller.js
|
||||
@@ -578,10 +493,7 @@ website/
|
||||
│ │ │ │ │ ├── users.router.js
|
||||
│ │ │ │ │ └── wiki.router.js
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ │ ├── account.controller.js
|
||||
│ │ │ │ │ ├── auth.controller.js
|
||||
│ │ │ │ │ ├── emailVerify.controller.js
|
||||
│ │ │ │ │ ├── emailVerify.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── invite.controller.js
|
||||
│ │ │ │ │ ├── invite.router.js
|
||||
@@ -605,6 +517,7 @@ website/
|
||||
│ │ │ │ │ ├── internal.controller.js
|
||||
│ │ │ │ │ └── internal.routes.js
|
||||
│ │ │ │ ├── player/
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── appeals.controller.js
|
||||
│ │ │ │ │ ├── appeals.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
@@ -613,8 +526,6 @@ website/
|
||||
│ │ │ │ │ ├── teams.controller.js
|
||||
│ │ │ │ │ └── teams.router.js
|
||||
│ │ │ │ ├── public/
|
||||
│ │ │ │ │ ├── engagement.controller.js
|
||||
│ │ │ │ │ ├── engagement.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── modules.controller.js
|
||||
│ │ │ │ │ ├── modules.router.js
|
||||
@@ -643,15 +554,11 @@ website/
|
||||
│ │ │ ├── brandAssets.js
|
||||
│ │ │ ├── db.js
|
||||
│ │ │ ├── discordAnnounce.js
|
||||
│ │ │ ├── engagementEmit.js
|
||||
│ │ │ ├── engagementRetentionPrune.js
|
||||
│ │ │ ├── engagementWorker.js
|
||||
│ │ │ ├── forumHtml.js
|
||||
│ │ │ ├── htmlShell.js
|
||||
│ │ │ ├── logger.js
|
||||
│ │ │ ├── mailer.js
|
||||
│ │ │ ├── navOverrides.js
|
||||
│ │ │ ├── newsNotify.js
|
||||
│ │ │ ├── pushDispatch.js
|
||||
│ │ │ ├── reservedNames.js
|
||||
│ │ │ ├── sanitizeHtml.js
|
||||
@@ -668,8 +575,7 @@ website/
|
||||
│ │ │ ├── themeResolve.js
|
||||
│ │ │ ├── totp.js
|
||||
│ │ │ ├── trustProxy.js
|
||||
│ │ │ ├── unsubscribeToken.js
|
||||
│ │ │ └── userNotificationsPrune.js
|
||||
│ │ │ └── unsubscribeToken.js
|
||||
│ │ ├── app.js
|
||||
│ │ ├── internalApp.js
|
||||
│ │ └── server.js
|
||||
@@ -698,33 +604,15 @@ website/
|
||||
│ │ ├── botScore.test.js
|
||||
│ │ ├── brandAssets.test.js
|
||||
│ │ ├── checkModuleIdentifiers.test.js
|
||||
│ │ ├── checkNoExternalHosts.test.js
|
||||
│ │ ├── contentReports.test.js
|
||||
│ │ ├── csp.test.js
|
||||
│ │ ├── emailCollisionSurfaces.test.js
|
||||
│ │ ├── emailConfig.model.test.js
|
||||
│ │ ├── emailTemplates.test.js
|
||||
│ │ ├── emailUniqueness.test.js
|
||||
│ │ ├── emailVerification.test.js
|
||||
│ │ ├── engagementAdmin.test.js
|
||||
│ │ ├── engagementCeilings.test.js
|
||||
│ │ ├── engagementDeliverability.test.js
|
||||
│ │ ├── engagementEmail.test.js
|
||||
│ │ ├── engagementEngine.test.js
|
||||
│ │ ├── engagementEngineSql.test.js
|
||||
│ │ ├── engagementInapp.test.js
|
||||
│ │ ├── engagementManifest.test.js
|
||||
│ │ ├── engagementRetention.test.js
|
||||
│ │ ├── engagementRetentionSql.test.js
|
||||
│ │ ├── engagementTemplatesAdmin.test.js
|
||||
│ │ ├── engagementTriggers.test.js
|
||||
│ │ ├── honeypot.test.js
|
||||
│ │ ├── htmlShell.test.js
|
||||
│ │ ├── inviteController.test.js
|
||||
│ │ ├── invites.test.js
|
||||
│ │ ├── loginProtection.test.js
|
||||
│ │ ├── mailer.test.js
|
||||
│ │ ├── mailTransports.test.js
|
||||
│ │ ├── mobileAuthBridge.model.test.js
|
||||
│ │ ├── mobileDeviceSessions.test.js
|
||||
│ │ ├── mobileSession.test.js
|
||||
@@ -735,7 +623,6 @@ website/
|
||||
│ │ ├── moduleClientChunk.test.js
|
||||
│ │ ├── moduleDeclared.test.js
|
||||
│ │ ├── moduleDocsSpec.test.js
|
||||
│ │ ├── moduleEngagementSeeds.test.js
|
||||
│ │ ├── moduleInstall.test.js
|
||||
│ │ ├── moduleLifecycle.test.js
|
||||
│ │ ├── moduleLoader.test.js
|
||||
@@ -743,8 +630,6 @@ website/
|
||||
│ │ ├── modules.model.test.js
|
||||
│ │ ├── moduleSchema.test.js
|
||||
│ │ ├── navOverrides.test.js
|
||||
│ │ ├── newsNotify.test.js
|
||||
│ │ ├── notificationChannelPrefs.test.js
|
||||
│ │ ├── notificationsRoutes.test.js
|
||||
│ │ ├── pages.model.test.js
|
||||
│ │ ├── passwordResetController.test.js
|
||||
@@ -769,7 +654,6 @@ website/
|
||||
│ │ ├── settingsTheming.test.js
|
||||
│ │ ├── slashCommands.test.js
|
||||
│ │ ├── ssoCallback.test.js
|
||||
│ │ ├── ssoEmailVerified.test.js
|
||||
│ │ ├── ssoState.test.js
|
||||
│ │ ├── ssoTrustedDevice.test.js
|
||||
│ │ ├── teamAccess.test.js
|
||||
@@ -790,15 +674,13 @@ website/
|
||||
│ │ ├── totp.test.js
|
||||
│ │ ├── trustedDevices.test.js
|
||||
│ │ ├── trustProxy.test.js
|
||||
│ │ ├── usernamePolicy.test.js
|
||||
│ │ └── userNotificationsSql.test.js
|
||||
│ │ └── usernamePolicy.test.js
|
||||
│ ├── tools/
|
||||
│ │ └── cliloc-export/
|
||||
│ │ ├── clilocexport.csproj
|
||||
│ │ ├── Program.cs
|
||||
│ │ └── README.md
|
||||
│ ├── .env.example
|
||||
│ ├── engagement-triggers.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
│ ├── routes.guards.json
|
||||
|
||||
@@ -13,111 +13,6 @@ action, whether it is required, and what happens if you do nothing.
|
||||
|
||||
---
|
||||
|
||||
## Engagement: rules, an in-app inbox, and three things that arrive switched off
|
||||
|
||||
**Required.** The engagement workstream, Phases 1–12. Three separate actions, and the two that matter
|
||||
most are the ones nothing will tell you about.
|
||||
|
||||
### What changed
|
||||
|
||||
The site now has a general notification engine. A **trigger** fires (a forum post, a house going
|
||||
IDOC, a governor being appointed), a **rule** decides who hears about it and through which
|
||||
**channel**, and a **template** decides what it says. All of it is admin-editable under
|
||||
**Admin → Engagement** — Rules, Audiences, Templates, Triggers, Send Log and Suppressions.
|
||||
|
||||
Players get an in-app inbox at **Account → Notifications**, with per-channel switches at **Account →
|
||||
Notification settings**, and the same inbox in the Android app.
|
||||
|
||||
### What you must do
|
||||
|
||||
Three things, in this order. The first is a required upgrade step for anyone running a shard; the
|
||||
second and third are only required if you were relying on the behaviour they change.
|
||||
|
||||
#### 1. Update the shard — the wire protocol is now 5
|
||||
|
||||
**Required if this deployment has a shard connected.** Update in this order:
|
||||
|
||||
1. the website (this image),
|
||||
2. the **UO module to 0.5.0** — **Admin → Modules**, paste the 0.5.0 release install-manifest
|
||||
URL, then **restart the server**: modules are read from disk at boot, so nothing changes until
|
||||
you do. It declares `coreApi ^1.9.0`, and a core older than that will refuse to *load* it (the
|
||||
install itself still succeeds) — the module then shows as failed on that screen, naming the
|
||||
version it needs. That is the check working, but it means step 1 has to be finished first;
|
||||
3. the **shard**, with `installer update` on the shard host. That is what pairs a protocol-5 sidecar
|
||||
with a protocol-5 overlay; the installer refuses to pair a mismatched one by design.
|
||||
|
||||
**The order matters, and step 2 is the one that starts the clock.** The protocol pin lives in the UO
|
||||
module's schema: on the first boot after the module updates, `uo_link_config.protocol` moves to 5 and
|
||||
the site starts sending `X-UOLink-Version: 5`. A shard still on 4 answers `409` from that moment on,
|
||||
so shard data stops. Nothing is lost and nothing is corrupted — the site degrades to showing the
|
||||
shard offline, exactly as it does when the sidecar is down — but it stays that way until the shard is
|
||||
updated. If you cannot update the shard in the same window, update the site and leave the module on
|
||||
its current version until you can.
|
||||
|
||||
Protocol 5 adds a decay schedule and owner account to `house.decay`, fee and owner state to
|
||||
`vendor.listing`, and a new `account.login.result`. Those three fields are what the shard-driven
|
||||
rules are built on; a shard on 4 could not feed them.
|
||||
|
||||
#### 2. Team notification email arrives switched off
|
||||
|
||||
**Required if your Teams rely on email.** Team email used to send automatically. It now runs through
|
||||
the engagement engine like everything else, as four rules — and like every rule on the platform, they
|
||||
are seeded **disabled**. Nothing mails until an operator switches one on.
|
||||
|
||||
Open **Admin → Engagement → Rules** and enable the Team rules you want. A banner on that screen says
|
||||
the same thing while they are all off, and disappears when one is enabled.
|
||||
|
||||
**Only email moved.** The push tickle and the Discord bridge are unchanged and kept working through
|
||||
the upgrade, so the app and your Discord channel behave exactly as before. Per-member Team
|
||||
preferences and unsubscribe links still apply above the rules.
|
||||
|
||||
#### 3. News push stops until you switch it on
|
||||
|
||||
**Required if you publish news and expect it to notify.** Publishing a news post used to send a push
|
||||
tickle directly. That call is now an engagement trigger, seeded — again — as a **disabled** rule. A
|
||||
deployment that upgrades and does nothing else tickles nobody on the next publish.
|
||||
|
||||
Same screen: **Admin → Engagement → Rules**, enable the news rule.
|
||||
|
||||
This is deliberate rather than an oversight. The alternative was to keep the old direct tickle beside
|
||||
the new trigger for one release, and an exception with no owner is one nobody removes.
|
||||
|
||||
### Behaviour changes that need no action
|
||||
|
||||
- **An unsubscribe link now turns off one channel, not everything.** The old link muted a user
|
||||
outright, which silenced push as well as mail. It now switches off exactly the channel the link was
|
||||
in.
|
||||
- **Email and push are opt-in; the in-app inbox is on.** A user who changes nothing receives in-app
|
||||
items and no mail. That matches the Team preference model already in use, and it is why enabling a
|
||||
rule does not by itself produce a mail storm.
|
||||
- **A rule cannot mail more people than its trigger allows.** Every trigger declares an audience
|
||||
ceiling, and a rule is checked against it — so a staff-only event cannot be given a public
|
||||
audience, whatever the rule says.
|
||||
- **Bounces now suppress an address.** A hard bounce or a rejected recipient stops further mail to
|
||||
that address and is listed under **Admin → Engagement → Suppressions**, which is also the only
|
||||
screen that can lift one. An address that has stopped receiving mail is the first thing to check
|
||||
there.
|
||||
|
||||
### If you do nothing
|
||||
|
||||
The site upgrades, the in-app inbox works, and no mail is sent — because every rule is off and email
|
||||
needs both a transport (see the SMTP entry below) and a user who opted in. What you lose silently is
|
||||
**Team email** and **news push**, both of which were sending before the upgrade and stop at it. If
|
||||
you also leave the UO module on its current version, the shard keeps working on protocol 4 and none
|
||||
of the shard-driven rules exist yet.
|
||||
|
||||
### Data
|
||||
|
||||
Nothing is dropped and no row is deleted. New tables cover rules, segments, templates, the outbox,
|
||||
the send log, cooldowns, suppressions, per-channel preferences and the in-app inbox.
|
||||
`team_notification_prefs` is kept and still consulted — per-Team granularity did not move.
|
||||
|
||||
**Nothing in this schema expires on its own.** Finished outbox rows, the send log and the suppression
|
||||
list are kept until you remove them; there is no retention sweep. On a busy deployment those tables
|
||||
grow without limit, which is worth knowing before it is worth acting on.
|
||||
|
||||
---
|
||||
|
||||
## Email addresses are now unique, and some accounts may lose theirs
|
||||
|
||||
**Required only if the dashboard says so.** Engagement Phase 1b.
|
||||
|
||||
Reference in New Issue
Block a user