The documentation half of Phase 11a (website#177, Module-uo#22). Three files,
each carrying one obligation from 6.0b's Phase 11a row.
**MODULE_API.md -> 1.8.0.** A seventh audience ceiling, `admin`, as a child of
`staff`. One addition, no removal, so minor -- every declaration valid under
1.7.0 is valid now and no stored value changes. 2.4's ceiling bullet now names
seven values rather than six, and says the thing a module author actually needs:
`admin` is the ONLY pair in the whole lattice with real containment, so
`permits('staff','admin')` holds and it is the only place `permits` is true
between two values below `authenticated`.
Also corrects two 2.3 rows that were stale rather than wrong: `ctx.inbox.push`
has not thrown since Phase 7, and both members now name the consumer that
actually calls them.
**modules/uo/API.md gains 5**, the full trigger catalogue -- which 6.0b asked for
in as many words ("the full trigger catalogue, its audiences and its ceilings,
not one entry"). The 24 triggers grouped by ceiling with the reason for each
ceiling; the four rows that deliberately do NOT ship with their recorded reasons;
the two that need a patch tier or a v5 overlay; the three things a rule cannot
express and are therefore done in the mapper; the three registered audiences; and
the ingest ORDERING, which is load-bearing rather than incidental.
**BACKEND_DESIGN.md -- the news publish path.** It described a direct
`pushDispatch.publish('news.post', ...)` from the create/publish-post path. That
call is gone: it is an emit through the engagement engine now, so news push rides
a rule, and core seeds that rule disabled. The note says so as an operator-visible
upgrade step, names the separate one-shot settings key and states what did NOT
change -- the announce legs and the post hooks still fire exactly as they did.
Co-Authored-By: Claude <noreply@anthropic.com>
260 lines
25 KiB
Markdown
260 lines
25 KiB
Markdown
# module-uo — its HTTP surface
|
|
|
|
The **72 URLs** `module-uo` serves, and the audience boundary that gates them. Frozen in the module's
|
|
own [`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json)
|
|
and documented operation-by-operation in its
|
|
[`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/swagger-fragment.json),
|
|
which core merges into `/api/docs.json` while the module is running — so the live Swagger UI is
|
|
always the most complete answer.
|
|
|
|
This page moved out of [`BACKEND_DESIGN.md`](../../website/BACKEND_DESIGN.md) §4 and §6.5 when
|
|
Phase 4 closed ([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) §2.7.2). Core's API contract
|
|
describes core's routes; these are the module's, and core cannot answer them with the module absent.
|
|
The tables and the reasoning are unchanged.
|
|
|
|
**Every URL is byte-identical to the one core served before the extraction** — that is §1.2 of the
|
|
module plan, and it is what lets the shipped Android app keep calling
|
|
`POST /api/v1/admin/shard/kick` and the Discord bot keep reading `/api/v1/public/shard/*` without
|
|
knowing a module answers now.
|
|
|
|
## 1. The mounts
|
|
|
|
| Mount | Routes | Tier and gate |
|
|
|---|---|---|
|
|
| `/api/v1/public/shard` | 19 | Anonymous. **Never site-mode gated** — the shard surface stays readable during maintenance, per feature audience. |
|
|
| `/api/v1/public/atlas` | 6 | Anonymous, and unlike `/shard` it **is** site-mode gated: nothing here touches the sidecar, it is parsed shard content. |
|
|
| `/api/v1/admin/shard` | 26 | Behind core's `isLoggedIn + noindex + staffOnly` group gate, then **mixed per route** — see below. |
|
|
| `/api/v1/admin/uo-link` | 7 | `adminOnly`. The sidecar connection config, its live status, the admin SSE stream and the town crier. |
|
|
| `/api/v1/player/shard` | 8 | `requireAuth`, **any role** — staff are a superset of players — and every handler is self-scoped to `req.user.id`. |
|
|
| `/api/v1/admin/users/:id/shard/*` | 6 | `adminOnly`. The module's routes hanging off a **core** resource, through core's `admin.users.detail` extension slot: core owns the user, the module owns what it knows about their game accounts. |
|
|
|
|
**`/admin/shard` is the one mixed prefix**, and it is mixed because it carries three different jobs:
|
|
|
|
- the **self-service account-linking** routes carry no gate beyond `staffOnly` — they are the same
|
|
handlers `/player/shard` serves, reached from the admin surface;
|
|
- the **in-game staff operations** (kick, ban, unban, broadcast, pages) carry `modAccess`
|
|
(admin + moderator, so editors are excluded);
|
|
- **`GET`/`PUT /admin/shard/visibility` are `adminOnly`**, a third tier above `modAccess`, because
|
|
they decide what *anonymous* visitors can see (§4). A moderator can ban a player but cannot decide
|
|
what the public internet reads.
|
|
|
|
Every admin write logs to core's one `activity_log`, through `ctx.activity.log`
|
|
([`MODULE_API.md`](../../website/MODULE_API.md) §2.7) — an admin action a module performs is not
|
|
allowed its own audit trail.
|
|
|
|
## 2. Public routes worth their own note
|
|
|
|
The full list is in the manifest and the merged spec. These are the ones that carried a design note
|
|
in core's contract before the extraction; the pre-3.0 ingest routes (`/shard/status`, `/feed`,
|
|
`/online`, `/economy`, `/houses`, `/idoc`, `/champs`, `/guilds`, `/governors` and the `/stream` SSE
|
|
feed) are described where their wire frames are, in
|
|
[`link/PLAN.md`](../../link/PLAN.md) §5 and
|
|
[`link/INTEGRATION.md`](../../link/INTEGRATION.md).
|
|
|
|
| Method | Path | Notes |
|
|
|---|---|---|
|
|
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
|
|
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
|
|
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
|
|
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
|
|
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
|
|
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
|
|
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§4 below), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
|
|
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
|
|
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
|
|
| GET | `/atlas/regions?facet=&q=` | named regions and the rectangles that placed each spawner |
|
|
| GET | `/atlas/landmarks?facet=&q=` | points of interest, labelled by `group` ("Covetous", not "Level 1") |
|
|
| GET | `/atlas/champions?facet=` | the **configured** altar roster. Not `/shard/champs`, which is the live board. |
|
|
| GET | `/atlas/meta` | facets, counts and when the atlas was parsed. Game-world facts only — the ServUO path, source hashes and any pending refresh are operator detail and live on the admin route. |
|
|
|
|
## 3. Admin routes worth their own note
|
|
|
|
The two content imports — the spawn atlas and the cliloc table — whose behaviour is a decision
|
|
rather than a passthrough. The shard-ops routes (`kick`, `ban`, `unban`, `broadcast`, `pages`), the
|
|
account-linking routes and the sidecar config under `/admin/uo-link` are in the merged spec.
|
|
|
|
| Method | Path | Purpose |
|
|
|---|---|---|
|
|
| GET | `/shard/atlas` | spawn-atlas status (`adminOnly`): the ServUO path, whether the tree is readable, whether it has drifted from what is loaded, counts, facets, and any refresh staged for review. The public `/atlas/meta` reports the game world only; the filesystem detail is here. |
|
|
| POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500** — `refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. |
|
|
| POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. |
|
|
| PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. |
|
|
| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): every source found now (base first, then `custom/` overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and `missingSources`. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. |
|
|
| POST | `/shard/clilocs/import` | reload after a client patch or an overlay edit; `{force}` ignores the hash gate, `{approve}` accepts a **vanished** source (refused by default — see the table notes above). **A missing path — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. |
|
|
| PUT | `/shard/clilocs/path` | point the site at a different cliloc base file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Overlays are read from `custom/` beside it either way. Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. |
|
|
|
|
## 4. Shard visibility — the audience boundary (Protocol 3.0)
|
|
|
|
Every shard-derived surface is gated by an **admin-configurable, per-feature and per-field** audience
|
|
setting. This **replaces** the static `PUBLIC_KINDS` allowlist that used to be the whole boundary.
|
|
Policy lives in `utils/shardVisibility.js`; rows live in `shard_feature_visibility`; the admin surface
|
|
is `GET`/`PUT /admin/shard/visibility` (`adminOnly`). Admin-facing guide:
|
|
[`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md). Design: [`link/v3.md`](../../link/v3.md) §3.
|
|
|
|
**The ladder.** `anonymous < logged_in < player < staff < admin`, each rung implying the ones below.
|
|
`viewerLevel(req)` resolves it: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
|
|
with a linked game account ⇒ `player`; moderator ⇒ `staff`; admin ⇒ `admin`. **Staff satisfy the
|
|
`player` rung without a linked account** (consistent with `/player/*` being role-agnostic).
|
|
**`editor` gets no shard privilege** — it is a content role, and mapping it to `staff` would silently
|
|
widen what editors see.
|
|
|
|
**Two invariants that are code, not configuration.** Both are enforced server-side and both reject
|
|
rather than silently ignore:
|
|
|
|
1. **`acct` and `webId` are admin-only, always.** They are not exposed as configurable fields, and a
|
|
stored row attempting to loosen them is discarded on read as well as rejected on write. A character
|
|
name is visible in game; the account behind it and the website user it links to are not.
|
|
The lock is on the field's **meaning, not one spelling**: `isLockedField(key)` matches a key that
|
|
*is* or *ends in* `acct`/`webId`, case-insensitively, so the flattened forms the read models emit
|
|
(`shapeHouse` → `ownerAcct`, `shapeGuild` → `leaderWebId`) are covered too. An exact-key check was
|
|
the original implementation and it let `GET /public/shard/idoc` serve `ownerAcct` anonymously.
|
|
2. **A kind absent from `KIND_FEATURE` is never broadcast below `admin`.** Fail closed. This is what
|
|
keeps the kind map a security boundary rather than a convenience filter, and it means a shard that
|
|
starts emitting an unknown event degrades to staff-only, never to public.
|
|
|
|
**Fail-closed everywhere else too.** An unreadable visibility config withholds every public frame; a
|
|
DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer
|
|
subscribes as `anonymous`. The ladder comparison uses **asymmetric** fallbacks by design — an unknown
|
|
*viewer* level floors to the bottom rung and an unknown *requirement* ceils to admin, so an
|
|
unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction
|
|
it picks, it fails open on one side.)
|
|
|
|
**Three enforcement points, one config:**
|
|
|
|
| Where | Mechanism |
|
|
|---|---|
|
|
| Routes | `requireFeature(name)` — **404** when the feature is disabled (don't leak that it exists), **403** when the caller is below its audience. `projectFeature` then strips out-of-rung fields from the body. |
|
|
| SSE (`utils/shardBroadcast.js`) | Per-connection filtering. A subscriber's rung is resolved **once at subscribe time and frozen** for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing. |
|
|
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
|
|
|
|
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
|
|
streams**. `PUBLIC_KINDS` still exists and is still exported (`utils/shardBroadcast.js`) but is now
|
|
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
|
|
|
|
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this
|
|
kind?"** — it is computed from the compiled *defaults*, so it cannot see an admin's changes. Use
|
|
`visibleKinds(level, config)`, which resolves against the live config. `/feed` uses it; it originally
|
|
used `PUBLIC_KINDS` and consequently kept serving `guild.join` to anonymous callers after an admin had
|
|
moved `guilds` to `staff`. `visibleKinds` deliberately ignores the `stream` flag: that governs SSE
|
|
fan-out only, so a feature whose live firehose ships off (market) stays readable from stored history.
|
|
|
|
**Every read path that returns shard data must call `projectFeature`.** The stored-history endpoints
|
|
are not exempt — `/feed` returns the same events the stream does, and returning them unprojected
|
|
reopens on the REST side exactly what the stream closes. Relatedly, `shardEvents.db.list` treats an
|
|
**empty** `kinds` array as "serve nothing", never "no filter"; the fall-through it used to take would
|
|
have turned a fully-gated config into a dump of the entire event log.
|
|
|
|
`projectFeature` walks **arrays and plain objects only**. A `Date`, `Buffer` or other class instance
|
|
is passed through as a value — rebuilding one key-by-key yields `{}`, which is the difference between
|
|
the pure-JSON wire frames and the DB-backed read models whose rows carry real `Date` columns.
|
|
|
|
**Defaults reproduce pre-3.0 behavior exactly**, so installing the framework is a no-op until an admin
|
|
changes something — with deliberate exceptions, which are the leaks it was written to close.
|
|
`/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 **24 event triggers** and
|
|
**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`
|
|
([`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.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` | Each is about one person's own property, account or character. All ten resolve through the frame's `ownerAcct` 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.
|