feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edgemain) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-01 13:59:04 +00:00
27 changed files with 4593 additions and 25 deletions

View File

@@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no
server/router/ routers + controllers, one directory per tier server/router/ routers + controllers, one directory per tier
server/model/ one directory per table family; nothing crosses the boundary server/model/ one directory per table family; nothing crosses the boundary
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
server/config/ the push stream catalog server/config/ the push stream catalog, the engagement triggers and audiences
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema() server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge server/db/purge.sql destructive; only ever run by an explicit purge
server/scripts/ the three checks: imports, the fragment, the frozen manifest server/scripts/ the three checks: imports, the fragment, the frozen manifest
@@ -112,6 +112,14 @@ client/vite.config.js the library build, the aliases, the not-bundled guard
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
``` ```
**What this module registers with core, beyond its routes.** Seven push streams, one announce leg
(the in-game town crier), a Team provider (a UO guild is a Team), one slash command, and — since
ENGAGEMENT.md Phase 11 — **24 engagement triggers and 3 audiences**. A trigger is a payload contract:
what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever
give it. Core learns none of the vocabulary; it holds ids, labels and ceilings. Declaring a trigger
sends nobody anything — every rule ships disabled. The catalogue, the four rows deliberately absent
and the reasons are in [`docs/modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md) §5.
**The three generated files are committed on purpose.** Two of them are what core reads instead of **The three generated files are committed on purpose.** Two of them are what core reads instead of
looking at this source — it never has it — and the third records which core they were proved against. looking at this source — it never has it — and the third records which core they were proved against.
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a

View File

@@ -1,6 +1,6 @@
{ {
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.", "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", "ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9",
"refName": "main @ the Teams cutover (website#161)" "refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)"
} }

View File

@@ -1,8 +1,8 @@
{ {
"id": "uo", "id": "uo",
"name": "Ultima Online", "name": "Ultima Online",
"version": "0.3.0", "version": "0.5.0",
"coreApi": "^1.3.0", "coreApi": "^1.9.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql", "schema": "server/db/schema.sql",

View File

@@ -0,0 +1,46 @@
// ── The module's own client paths, in one place ────────────────────────────
//
// Every link a notification puts in front of a player is a path into this
// module's SPA routes, and Phase 11b's live walk found that not one of them was
// right: the declared examples all read `/shard/…` (module.json's `mounts`), the
// bodies hard-coded a mixture of `/shard/…` and `/player/uo/…`, and the mapper
// populated none of the URL variables at all — so every in-universe letter shipped
// with an empty href and every template preview showed a dead one.
//
// **The prefix is the module ID, not the mount.** `registry.registerRoutes`
// prefixes a module's client routes with `<id>/` and nothing else
// (`client/src/modules/registry.js`), which is why `module.json`'s `mounts` is not
// the answer — that field says what the module CLAIMS, and the router says where
// it landed. `client/src/entry.jsx`'s own `registerNav` is the check: the hrefs it
// gives the sidebar are these, and if the two ever disagree the sidebar is right.
//
// Kept server-side and shared by BOTH the trigger declarations (their `example`s,
// which the template editor previews and test-sends with) and the seeded bodies,
// so a route that moves is one edit rather than thirty.
const ID = 'uo'
const PATHS = {
shard: `/${ID}/shard`,
champs: `/${ID}/champs`,
guilds: `/${ID}/guilds`,
governors: `/${ID}/governors`,
houses: `/${ID}/houses`,
atlas: `/${ID}/atlas`,
leaderboards: `/${ID}/leaderboards`,
market: `/${ID}/market`,
// Self-service and staff areas sit under core's own wrappers, so they carry
// core's prefix as well as the module's.
characters: `/player/${ID}/characters`,
ops: `/admin/${ID}/ops`,
}
/** One guild's roster, when the frame names a guild; the list otherwise. */
const guildPath = (guildId) =>
(guildId === undefined || guildId === null ? PATHS.guilds : `${PATHS.guilds}/${guildId}`)
/** One vendor's page, when the frame names one; the market otherwise. */
const vendorPath = (serial) =>
(serial ? `${PATHS.market}/vendors/${serial}` : PATHS.market)
module.exports = { PATHS, guildPath, vendorPath }

View File

@@ -0,0 +1,969 @@
// ── module-uo's shipped message bodies and rules ───────────────────────────
//
// ENGAGEMENT.md Phase 11b, decisions 8, 9 and 10; the mechanism is decision 7's
// `api.registerEngagementSeeds` (MODULE_API.md §1.1, 1.9.0). `shardTriggers.js`
// says what an event IS and who it is about; this file says what the message
// READS like, and which rules an operator finds waiting on the Rules screen.
//
// ── Why any of this is bespoke at all ──────────────────────────────────────
//
// §4.6.1 property 1 is that a trigger needs NO authoring: `notify.event` plus the
// structural projection renders any declaration as a title, an intro and a link.
// That property is real and nine of these twenty-five triggers use it — see
// PLAIN below. What it cannot do is have a voice, and the org lead's decision 8
// is that the game-powered families should read from inside Britannia rather than
// from a notifications system.
//
// **The sender is per family, not one voice across all sixteen**, and that was
// the decision rather than the obvious answer. Lord Blackthorn writing to you
// personally about a champion spawn is a shard where the letter about your
// governorship means nothing. So the court writes about the crown's business —
// the seat, the ballot — and everything else has the sender its own subject
// implies:
//
// the Office of Deeds houses a clerk with a ledger and a duty to warn
// the Merchants' Guild vendors a factor rendering accounts
// a guild herald guild events
// the town crier champion spawns
// a guildmaster skills, quests
// the Chronicler deaths
// the keeper of the rolls leaderboards
// Lord Blackthorn's court governors, elections
//
// **Nine bodies stay PLAIN, and the line is drawn where fiction costs something
// real** (decision 9). 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 an operator reading `uo.cheat.detected` at two in the morning
// wants a name, a rule and a timestamp rather than a scroll. Those nine name
// core's `notify.event` / `inapp.event` and author nothing.
//
// **Both channels, and the digest deliberately neither.** Each in-universe
// trigger ships an `email` body (the letter) and an `inapp` body in the same
// voice, because 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 one list is not a
// letter from anybody, and dressing a bulleted summary as correspondence is where
// this device stops being charming.
//
// ── Three things to know before editing a body ─────────────────────────────
//
// 1. **No conditionals, ever.** An unset optional interpolates to the EMPTY
// STRING (`interpolate.js`), so a sentence built around one gets a hole in
// it. The fragments `shardTriggers.js` declares — `houseLabel`, `slainBy`,
// `atPlace` — exist for exactly this and are the only safe way to put an
// optional inside a clause. A trailing fragment carries its OWN leading
// space; do not add one.
// 2. **No brand, no colour, no logo** (§4.6.1 property 2). `siteName`,
// `siteUrl`, `logoUrl` and `year` are ambient and supplied by the renderer,
// so one prebuilt image mails in whatever shard's identity it is running as.
// An in-universe body is UO-specific and still shard-agnostic.
// 3. **`seedVersion` is the "improve a default without stealing an operator's
// work" mechanism.** Bump it when a body changes and the seeder updates
// rows where `customized = 0` and skips rows where it is 1. Do NOT bump it
// for a comment.
//
// An operator running a shard whose canon is not Blackthorn's edits these rows;
// that is what the template editor is for, and `customized = 1` then protects the
// edit from every later seed.
// ── Block helpers, so the bodies below read as content ─────────────────────
const text = (id, body, opts = {}) => ({
id,
type: 'email.text',
props: opts.muted ? { text: body, muted: true } : { text: body },
})
const heading = (id, body, level = 'h1') => ({
id,
type: 'email.heading',
props: { level, text: body },
})
const button = (id, label, url, textLead) => ({
id,
type: 'email.button',
props: textLead ? { label, url, textLead } : { label, url },
})
const divider = (id) => ({ id, type: 'email.divider', props: {} })
// The unsubscribe pair every in-universe EMAIL body ends with. In the plain
// register on purpose: an unsubscribe link is a legal and practical affordance,
// not part of the fiction, and a reader hunting for it should not have to parse a
// herald to find it.
const unsubscribe = () => [
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
]
/** An email body: subject line, blocks, the unsubscribe pair appended. */
const email = (key, name, triggerId, subject, blocks) => ({
key,
name,
channel: 'email',
triggerId,
seedVersion: 1,
subject,
blocks: [...blocks, ...unsubscribe()],
})
/**
* An in-app body — the same voice, three blocks.
*
* The renderer maps them onto `user_notifications` BY ROLE (`renderInappByKey`):
* the heading is the row's title, the button is its one action, everything else
* is the body. No unsubscribe line: an inbox item links to the preferences screen
* that an unsubscribe link would only reach anyway.
*/
const inapp = (key, name, triggerId, title, body, action, url) => ({
key,
name,
channel: 'inapp',
triggerId,
seedVersion: 1,
subject: null,
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
})
// ── The sixteen in-universe bodies ─────────────────────────────────────────
const { PATHS } = require('./clientPaths')
const TEMPLATES = [
// ── The Office of Deeds — houses ────────────────────────────────────────
//
// A clerk, not a poet. The register is bureaucratic-formal because that is what
// makes the WARNING land: an office that keeps a ledger and is obliged to tell
// you before the ledger is amended.
email(
'uo.house.idoc-warning',
'House — decay warning (Office of Deeds)',
'uo.house.idoc_warning',
// `houseLabel`, not `{{region}}`: a subject line is the one place a hole is
// unmissable, and a house outside a named region rendered “thy house at ”.
// A LABEL always has a value; that is what separates it from a fragment.
'A notice concerning {{houseLabel}}',
[
heading('h', 'From the Office of Deeds'),
text('p1',
'Be it known that {{houseLabel}}, recorded to thy name, is this day found {{stageLabel}}. '
+ 'A house left untended passes in time out of thy keeping, and the deed with it.'),
text('p2',
'Visit the house and refresh it, and the ledger is set right. This office keeps no '
+ 'record of a house once it has fallen.'),
text('where', '{{whereLine}}', { muted: true }),
button('cta', 'Review thy holdings', '{{houseUrl}}', 'Thy holdings are listed here:'),
],
),
inapp(
'uo.house.idoc-warning-inapp',
'House — decay warning (in-app)',
'uo.house.idoc_warning',
'The Office of Deeds sends word',
'{{houseLabel}} is found {{stageLabel}}. Refresh it, or in time it passes out of thy keeping.',
'Review thy holdings',
'{{houseUrl}}',
),
email(
'uo.house.collapsed',
'House — collapsed (Office of Deeds)',
'uo.house.collapsed',
'The deed to thy house has been struck from the ledger',
[
heading('h', 'From the Office of Deeds'),
text('p1',
'It falls to this office to inform thee that {{houseLabel}} has fallen, and the deed '
+ 'recorded to thy name is struck from the ledger.'),
text('p2',
'What stood within is scattered where it stood, and the ground is open to any who would '
+ 'build there. This office is able to restore nothing.'),
text('where', '{{whereLine}}', { muted: true }),
],
),
inapp(
'uo.house.collapsed-inapp',
'House — collapsed (in-app)',
'uo.house.collapsed',
'Thy house has fallen',
'{{houseLabel}} has fallen, and the deed is struck from the ledger. The ground is open to any who would build there.',
'Review thy holdings',
PATHS.houses,
),
// The one letter this office sends that is not a warning (Phase 11b decision
// 11). It is the same clerk and the same ledger, which is the point: an office
// that only ever writes when something is wrong teaches a reader to dread its
// seal, and the notice that the ledger is set right is the cheapest possible
// way not to. It is also why `uo.house.refreshed` is a trigger at all — the
// cancellation is the mechanism, this is the message.
email(
'uo.house.refreshed',
'House — refreshed (Office of Deeds)',
'uo.house.refreshed',
'The ledger is set right for {{houseLabel}}',
[
heading('h', 'From the Office of Deeds'),
text('p1',
'This office records that {{houseLabel}}, held in thy name, has been refreshed and '
+ 'stands in good repair.{{fromLine}}'),
text('p2',
'No further notice will be sent concerning it. Should it fall into disrepair again, '
+ 'thou wilt hear from us before the deed is touched.'),
button('cta', 'Review thy holdings', '{{houseUrl}}', 'Thy holdings are listed here:'),
],
),
inapp(
'uo.house.refreshed-inapp',
'House — refreshed (in-app)',
'uo.house.refreshed',
'The Office of Deeds sends word',
'{{houseLabel}} has been refreshed and stands in good repair.{{fromLine}}',
'Review thy holdings',
'{{houseUrl}}',
),
// ── The Merchants' Guild — vendors ──────────────────────────────────────
//
// A factor rendering accounts: precise about money, unsentimental about
// consequence. The numbers are the point of the message, so they are in the
// body rather than in a muted footnote.
email(
'uo.vendor.expiring',
'Vendor — fees due (Merchants Guild)',
'uo.vendor.expiring',
'Accounts outstanding on {{shopLabel}}',
[
heading('h', 'From the Merchants Guild'),
text('p1',
'Good day. The Guild renders accounts on {{shopLabel}}, and finds them wanting. '
+ 'Some {{hoursRemaining}} hours remain before the keeper is dismissed and the wares '
+ 'returned whence they came.'),
text('p2',
'A deposit set against the account settles the matter. The Guild holds no goods for a '
+ 'merchant who has ceased to pay for their keeping.'),
text('ledger', '{{ledgerLine}}', { muted: true }),
button('cta', 'Attend to thy shop', '{{marketUrl}}', 'Thy shop stands here:'),
],
),
inapp(
'uo.vendor.expiring-inapp',
'Vendor — fees due (in-app)',
'uo.vendor.expiring',
'The Merchants Guild renders accounts',
'{{shopLabel}} has some {{hoursRemaining}} hours before the keeper is dismissed and the wares returned. A deposit settles it.',
'Attend to thy shop',
'{{marketUrl}}',
),
email(
'uo.vendor.sale',
'Vendor — a sale (Merchants Guild)',
'uo.vendor.sale',
'A sale is entered against {{shopLabel}}',
[
heading('h', 'From the Merchants Guild'),
text('p1',
'The Guild enters a sale against {{shopLabel}}: {{itemLine}}, for {{price}} gold.'),
text('p2',
'The takings are held by thy keeper until thou callest for them.'),
text('ledger', '{{ledgerLine}}', { muted: true }),
],
),
inapp(
'uo.vendor.sale-inapp',
'Vendor — a sale (in-app)',
'uo.vendor.sale',
'A sale at thy shop',
'{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.',
'Open the market',
PATHS.market,
),
// ── A guild herald ──────────────────────────────────────────────────────
//
// Announcements to a body of people rather than to a person, which is what the
// `members` audience is — so the second person plural, and no "thy".
email(
'uo.guild.left',
'Guild — a member departs (herald)',
'uo.guild.left',
'A departure from {{guildName}}',
[
heading('h', 'A notice to the company'),
text('p1',
'{{memberLabel}} is no longer counted among {{guildName}}. The rolls have been amended.'),
button('cta', 'Read the roll', '{{guildUrl}}', 'The roll stands here:'),
],
),
inapp(
'uo.guild.left-inapp',
'Guild — a member departs (in-app)',
'uo.guild.left',
'A departure from {{guildName}}',
'{{memberLabel}} is no longer counted among the company. The rolls have been amended.',
'Read the roll',
'{{guildUrl}}',
),
email(
'uo.guild.disbanded',
'Guild — disbanded (herald)',
'uo.guild.disbanded',
'{{guildName}} is dissolved',
[
heading('h', 'A notice to the company'),
text('p1',
'Be it known that {{guildName}} is dissolved. Its charter is void, its rolls are closed, '
+ 'and those who wore its colours wear them no longer.'),
text('p2',
'What was held in common is held in common no more.'),
],
),
inapp(
'uo.guild.disbanded-inapp',
'Guild — disbanded (in-app)',
'uo.guild.disbanded',
'{{guildName}} is dissolved',
'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.',
'Open the shard',
PATHS.shard,
),
// ── Lord Blackthorn's court — the crown's business ──────────────────────
//
// **The letter the whole voice decision was chosen to make possible**
// (decision 10). Note what it is NOT: it is not the town's bulletin. The
// announcement below it says a city has a governor; this says a person has a
// duty. They are two rules and two bodies for exactly that reason.
email(
'uo.governor.appointed',
'Governor — thy appointment (the court)',
'uo.governor.appointed',
'The seat of {{city}} passes to thee',
[
heading('h', 'By the hand of Lord Blackthorn'),
text('p1',
'{{governorName}} — the people of {{city}} have named thee their Governor{{inSuccessionTo}}, '
+ 'and the Crown confirms it.'),
text('p2',
'The seat carries duties as well as honours. A city is judged by what its Governor '
+ 'troubles to build, and by what is allowed to fall into disrepair while they hold '
+ 'the office. See that {{city}} is the better for thy tenure.'),
text('p3',
'The Crown will not govern in thy stead, nor will it stand between thee and those who '
+ 'gave thee the seat. They may take it back.'),
button('cta', 'Take up the seat', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
],
),
inapp(
'uo.governor.appointed-inapp',
'Governor — thy appointment (in-app)',
'uo.governor.appointed',
'Thou art named Governor of {{city}}',
'The people of {{city}} have named thee their Governor{{inSuccessionTo}}, and the Crown confirms it. The seat carries duties as well as honours.',
'Take up the seat',
'{{governorsUrl}}',
),
email(
'uo.governor.elected',
'Governor — a city decides (the court)',
'uo.governor.elected',
'{{city}} has named a Governor',
[
heading('h', 'Proclaimed from the court of Lord Blackthorn'),
text('p1',
'Let it be known throughout the realm that the people of {{city}} have named '
+ '{{governorName}} their Governor{{inSuccessionTo}}.'),
text('p2',
'Those with business in {{city}} may address it to the new seat.'),
button('cta', 'See the offices of the realm', '{{governorsUrl}}'),
],
),
inapp(
'uo.governor.elected-inapp',
'Governor — a city decides (in-app)',
'uo.governor.elected',
'{{city}} has named a Governor',
'{{governorName}} holds the seat of {{city}}{{inSuccessionTo}}. Those with business there may address it to the new seat.',
'See the offices of the realm',
'{{governorsUrl}}',
),
email(
'uo.election.opened',
'Election — the ballot opens (the court)',
'uo.election.opened',
'{{phaseLabel}} in {{city}}',
[
heading('h', 'Proclaimed from the court of Lord Blackthorn'),
text('p1',
'{{phaseLabel}} in {{city}}.{{candidateNote}}'),
text('p2',
'Those who hold the loyalty of the city may speak. Attend before {{autoPickWhen}}: '
+ 'after that hour the matter is decided without thee, and the Crown will hear no '
+ 'complaint from any who could have spoken and did not.'),
button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
],
),
inapp(
'uo.election.opened-inapp',
'Election — the ballot opens (in-app)',
'uo.election.opened',
'{{phaseLabel}} in {{city}}',
'Attend before {{autoPickWhen}} — after that hour the matter is decided without thee.{{candidateNote}}',
'Attend the city',
'{{governorsUrl}}',
),
// ── The town crier — come and see ───────────────────────────────────────
//
// Short, loud, and about NOW. A crier does not write letters; these two are the
// shortest bodies in the file on purpose, because their whole job is to get
// somebody to log in within the hour.
email(
'uo.champ.started',
'Champion spawn — begun (town crier)',
'uo.champ.started',
'Hear ye — {{spawnName}} stirs',
[
heading('h', 'Hear ye, hear ye'),
text('p1',
'Word from the roads: {{spawnName}} stirs{{atPlace}}. Those with the stomach for it '
+ 'had best go now — such things do not wait.'),
button('cta', 'See what stirs', '{{champsUrl}}'),
],
),
inapp(
'uo.champ.started-inapp',
'Champion spawn — begun (in-app)',
'uo.champ.started',
'{{spawnName}} stirs',
'Word from the roads: {{spawnName}} stirs{{atPlace}}. Such things do not wait.',
'See what stirs',
'{{champsUrl}}',
),
email(
'uo.champ.boss-up',
'Champion spawn — the champion walks (town crier)',
'uo.champ.boss_up',
'Hear ye — the champion of {{spawnName}} walks',
[
heading('h', 'Hear ye, hear ye'),
text('p1',
'{{bossName}} walks{{atPlace}}. The lesser things are spent; what remains is the '
+ 'reason anyone came.'),
button('cta', 'See what walks', '{{champsUrl}}'),
],
),
inapp(
'uo.champ.boss-up-inapp',
'Champion spawn — the champion walks (in-app)',
'uo.champ.boss_up',
'The champion of {{spawnName}} walks',
'{{bossName}} walks{{atPlace}}. The lesser things are spent.',
'See what walks',
'{{champsUrl}}',
),
// ── A guildmaster of the craft ──────────────────────────────────────────
email(
'uo.skill.capped',
'Skill — mastery reached (guildmaster)',
'uo.skill.capped',
'{{characterName}} has mastered {{skill}}',
[
heading('h', 'From the guildmaster of {{skill}}'),
text('p1',
'{{characterName}} — thou hast carried {{skill}} as far as it will be carried. '
+ '{{cap}} is the whole of it; there is no further mark to reach.'),
text('p2',
'What thou dost with it is thine own affair. The guild has taught thee what it knows.'),
button('cta', 'Read thy character', PATHS.characters),
],
),
inapp(
'uo.skill.capped-inapp',
'Skill — mastery reached (in-app)',
'uo.skill.capped',
'{{characterName}} has mastered {{skill}}',
'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.',
'Read thy character',
PATHS.characters,
),
email(
'uo.quest.complete',
'Quest — completed (guildmaster)',
'uo.quest.complete',
'{{characterName}} has seen {{quest}} through',
[
heading('h', 'A matter concluded'),
text('p1',
'{{characterName}} has seen {{quest}} through to its end. It is written down, which is '
+ 'more than most who set out on it can say.'),
button('cta', 'Read thy character', PATHS.characters),
],
),
inapp(
'uo.quest.complete-inapp',
'Quest — completed (in-app)',
'uo.quest.complete',
'{{quest}} — concluded',
'{{characterName}} has seen {{quest}} through to its end. It is written down.',
'Read thy character',
PATHS.characters,
),
// ── The Chronicler of the Dead ──────────────────────────────────────────
//
// Dry to the point of dark, and deliberately so: this is a killfeed some
// players want and most do not (§8.6), so its rule ships off and its body reads
// as a clerk making an entry rather than as the game commiserating.
email(
'uo.character.death',
'Death — an entry (the Chronicler)',
'uo.character.death',
'An entry concerning {{characterName}}',
[
heading('h', 'From the Chronicle of the Dead'),
text('p1',
'An entry is made: {{characterName}} has fallen{{slainBy}}.'),
text('p2',
'The Chronicle notes the fact and offers no opinion on it. Britannia is generous with '
+ 'second chances and keeps a record of every one.'),
],
),
inapp(
'uo.character.death-inapp',
'Death — an entry (in-app)',
'uo.character.death',
'{{characterName}} has fallen',
'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.',
'Read thy character',
PATHS.characters,
),
email(
'uo.character.murdered',
'Murder — an entry (the Chronicler)',
'uo.character.murdered',
'A murder is entered concerning {{characterName}}',
[
heading('h', 'From the Chronicle of the Dead'),
text('p1',
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.'),
text('p2',
'The Chronicle records the name of the guilty where it is known. What is done with '
+ 'that name is a matter for the living.'),
],
),
inapp(
'uo.character.murdered-inapp',
'Murder — an entry (in-app)',
'uo.character.murdered',
'{{characterName}} was murdered',
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.',
'Read thy character',
PATHS.characters,
),
// ── The keeper of the rolls ─────────────────────────────────────────────
email(
'uo.points.rank-changed',
'Leaderboard — the first place changes (keeper of the rolls)',
'uo.points.rank_changed',
'A new name heads the roll of {{boardLabel}}',
[
heading('h', 'From the keeper of the rolls'),
text('p1',
'The roll of {{boardLabel}} is amended. {{standingLine}}'),
text('p2',
'A roll is only ever the state of a thing on the day it was read.'),
button('cta', 'Read the roll', PATHS.leaderboards),
],
),
inapp(
'uo.points.rank-changed-inapp',
'Leaderboard — the first place changes (in-app)',
'uo.points.rank_changed',
'A new name heads {{boardLabel}}',
'The roll of {{boardLabel}} is amended. {{standingLine}}',
'Read the roll',
PATHS.leaderboards,
),
]
// **`unsubscribeUrl` is not declared here, and that is core's doing.** A
// trigger-bound template takes its variable list from the TRIGGER's declaration
// (`templates.variablesFor`), and a trigger has no business declaring a fact
// about how the mail was delivered — so core adds the per-delivery variables to
// that path (`templateSeeds.DELIVERY_VARIABLES`, added in this same phase).
// Without it the bodies below would render their unsubscribe link correctly and
// then refuse the first operator who tried to EDIT one, on the save-time
// undeclared-variable check.
// ── The twenty-five rules, every one of them off ───────────────────────────
//
// **`enabled = 0` is not a parameter** (Q3) — `registerEngagementSeeds` ignores
// any value passed for it. This is a catalogue an operator turns on, not a switch
// that floods anybody the day they upgrade.
//
// **One rule group, `triggers-v1`, and the choice matters** (MODULE_API 1.9.0). A
// group is seeded ONCE, so a rule appended to this list later reaches fresh
// installs only. That is correct for this set — it is the module's first — and it
// is exactly the trap 11a's seed-key finding names: a twenty-sixth trigger added
// in a future version wants its OWN group, or the deployments that most need it
// will never see it.
//
// The generic body is named deliberately wherever it appears. `notify.event` plus
// the structural projection is the right answer for a message whose content is
// "this happened, here is the link", and nine of these rules say so.
const CHANNELS_OWNER = ['email', 'inapp']
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
/** In-universe: both bodies are this module's, the digest is core's. */
const bodies = (key) => ({
email: `uo.${key}`,
inapp: `uo.${key}-inapp`,
digest: 'notify.digest',
})
/** Plain: core's generic bodies, no authoring (§4.6.1 property 1). */
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
const RULES = [
// ── Owned asset at risk ────────────────────────────────────────────────
{
trigger_id: 'uo.house.idoc_warning',
name: 'House — decay warning',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('house.idoc-warning'),
// A day, per HOUSE (the trigger's `subjectKey`). A house crossing two stages
// in an afternoon is one warning; a player with three decaying houses still
// hears about all three, which is the case `subjectKey` exists for.
cooldown_seconds: 86_400,
// **A quarter of an hour of grace, and something that cancels it.** A player
// who is standing in the house when it ticks over refreshes it within
// seconds; mailing them anyway is how a warning system teaches people to
// ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this.
delay_seconds: 900,
// **Both outcomes, and the refresh is the one the delay is FOR.** A collapse
// inside the window makes the warning pointless; a refresh inside it makes
// the warning wrong. Phase 11b's live walk found that only the first was
// named here, so the good outcome — the player fixing the thing they were
// about to be warned about — still produced the letter.
cancel_on: ['uo.house.collapsed', 'uo.house.refreshed'],
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.house.refreshed',
name: 'House — refreshed',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('house.refreshed'),
// A day, per house, like the warning it answers — a player refreshing the
// same house twice in an afternoon does not need telling twice. No delay:
// there is no bad outcome this could be waiting to be overtaken by.
//
// **This rule is not what does the cancelling.** `cancel_on` is read off the
// WARNING's rule and fires whether or not this rule is enabled, so an
// operator who wants the cancellation and not the reassurance simply leaves
// this one off — which, since every seeded rule ships disabled, is the
// default.
cooldown_seconds: 86_400,
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.house.collapsed',
name: 'House — collapsed',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('house.collapsed'),
// No cooldown and no delay. A collapse is terminal, it happens once per
// house, and there is nothing it could be waiting to be cancelled by.
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.vendor.expiring',
name: 'Vendor — fees due',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('vendor.expiring'),
// A day per vendor. The mapper already fires only on the CROSSING into the
// window, so this guards the case where a vendor is repeatedly deposited into
// and drawn back down over the same day.
cooldown_seconds: 86_400,
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.vendor.sale',
name: 'Vendor — a sale',
// **Dormant on most shards, and the description has to say so.**
// `vendor.sale` lives in `servuo-plugins/patches/` — the opt-in patch tier
// that ADDS a `PlayerVendorSale` EventSink to core ServUO — so a shard that
// declined the tier emits it never. That is dormant, not broken, and an
// operator switching this on and seeing nothing deserves to know why.
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('vendor.sale'),
// An hour per vendor. A busy shop is exactly what the digest is for; one
// mail per longsword is how a feature earns an unsubscribe.
cooldown_seconds: 3600,
max_sends_per_hour: 500,
},
// ── Personal security — PLAIN (decision 9) ─────────────────────────────
//
// A security notice must be distinguishable from flavour. A failed-login mail
// written as "a stranger sought entry to thy account" is indistinguishable in
// register from the phishing mail it is warning about.
{
trigger_id: 'uo.account.login_failed',
name: 'Account — failed game login',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
// An hour per account. A credential-stuffing run is a hundred attempts in a
// minute and one mail is the useful outcome.
cooldown_seconds: 3600,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.account.unlinked',
name: 'Account — game account unlinked',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
// ── Personal milestone ─────────────────────────────────────────────────
{
trigger_id: 'uo.skill.capped',
name: 'Skill — mastery reached',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('skill.capped'),
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.quest.complete',
name: 'Quest — completed',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('quest.complete'),
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.character.death',
name: 'Character — death',
// §8.6: a killfeed some players want and most do not. Off like everything
// else here, and its per-channel preference defaults to off as well.
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('character.death'),
// An hour per character. Dying repeatedly is a normal afternoon in Britannia.
cooldown_seconds: 3600,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.character.murdered',
name: 'Character — murdered',
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('character.murdered'),
cooldown_seconds: 3600,
max_sends_per_hour: 500,
},
// ── Social / civic ─────────────────────────────────────────────────────
{
trigger_id: 'uo.guild.left',
name: 'Guild — a member departs',
// `members`, which resolves to the recipient set the event carries — the
// roster resolved through `shard_account_links`. Not `authenticated`, and the
// trigger's ceiling would refuse that anyway.
audience: 'members',
channels: CHANNELS_OWNER,
template_keys: bodies('guild.left'),
// An hour per guild. A guild shedding six members in an afternoon sends one.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.guild.disbanded',
name: 'Guild — disbanded',
audience: 'members',
channels: CHANNELS_OWNER,
template_keys: bodies('guild.disbanded'),
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
{
trigger_id: 'uo.governor.appointed',
name: 'Governor — thy appointment',
// **The letter, and it is its own rule** (decision 10). An operator may run
// the announcement below and leave this off, or the reverse; that is the
// whole reason this is a second trigger rather than a second audience.
audience: 'owner',
channels: CHANNELS_OWNER,
template_keys: bodies('governor.appointed'),
cooldown_seconds: 0,
max_sends_per_hour: 100,
},
{
trigger_id: 'uo.governor.elected',
name: 'Governor — a city decides',
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: bodies('governor.elected'),
// An hour per CITY (the trigger's `subjectKey`): a city that flips its seat
// twice in an hour is a shard being restarted, not two elections.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.election.opened',
name: 'Election — the ballot opens',
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: bodies('election.opened'),
// **No delay, and that is the point of this trigger.** It carries
// `autoPickAt` — a real deadline — and a call to action delivered after the
// hour it names is worse than none at all.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
// ── Come online now ────────────────────────────────────────────────────
{
trigger_id: 'uo.champ.started',
name: 'Champion spawn — begun',
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: bodies('champ.started'),
// Per SPAWN, and short: the whole value is timeliness.
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.champ.boss_up',
name: 'Champion spawn — the champion walks',
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: bodies('champ.boss-up'),
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.server.up',
name: 'Shard — came online',
// PLAIN (decision 9): infrastructure. A crier announcing that the world
// exists again is a joke that stops being funny during an outage.
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: GENERIC,
// **The cooldown table's stress test** (§8.6). `uo.server.up`/`down` declare
// NO `subjectKey`, so the cooldown subject is the recipient: an hour means a
// shard flapping six times in a minute produces one mail, not six.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.server.down',
name: 'Shard — went offline',
audience: 'subscribers',
channels: CHANNELS_BROADCAST,
template_keys: GENERIC,
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
// ── Leaderboard ────────────────────────────────────────────────────────
{
trigger_id: 'uo.points.rank_changed',
name: 'Leaderboard — the first place changes',
audience: 'subscribers',
channels: CHANNELS_OWNER,
template_keys: bodies('points.rank-changed'),
// Six hours per board. A contested top spot changes hands all evening.
cooldown_seconds: 21_600,
max_sends_per_hour: 500,
},
// ── Staff-facing — PLAIN (decision 9) ──────────────────────────────────
//
// A moderator on call at two in the morning wants a name, a rule, a location
// and a timestamp. `notify.event` plus the structural projection gives exactly
// that, and a scroll would bury it.
{
trigger_id: 'uo.page.new',
name: 'Staff — a player opened a help page',
audience: 'staff',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.cheat.detected',
name: 'Staff — the cheat detector fired',
audience: 'staff',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
// An hour per character: a detector firing every tick on one player is one
// report, and the second report an hour later is the useful signal that it
// has not stopped.
cooldown_seconds: 3600,
max_sends_per_hour: 500,
},
// ── Operator-facing — PLAIN, and digest-shaped by nature ───────────────
{
trigger_id: 'uo.audit.staff_action',
name: 'Admin — staff actions in game',
// `admin`, not `staff` (§8.6): a digest of what moderators did is not for
// moderators. This is the rule the new ceiling exists for.
audience: 'admin',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'uo.economy.milestone',
name: 'Admin — the economy crossed a threshold',
audience: 'admin',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 100,
},
{
trigger_id: 'uo.world.saved',
name: 'Admin — the world saved',
audience: 'admin',
channels: CHANNELS_OWNER,
template_keys: GENERIC,
// **Six hours, and it should never be instant** (§8.6). A shard saves every
// few minutes; this exists so an operator can notice that it STOPPED.
cooldown_seconds: 21_600,
max_sends_per_hour: 24,
},
]
const RULE_GROUPS = [{
key: 'triggers-v1',
note: 'UO notifications stay off until an operator enables one',
rules: RULES,
}]
module.exports = { TEMPLATES, RULES, RULE_GROUPS }

View File

@@ -0,0 +1,99 @@
// ── module-uo's registered audiences ───────────────────────────────────────
//
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
// linked a game account".
//
// **This is a different mechanism from the `members` audience the guild triggers
// use, and the difference is worth stating because the words are the same.** A
// guild event is about the members of THAT guild, which is a different answer for
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
// and the access-checked set travels on the envelope as `recipientUserIds`
// instead (Phase 6, decision 2). What is here answers the same question every
// time it is asked, which is exactly what makes it composable and storable.
//
// **Four rules, all of them from §5.1a:**
//
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
// list and a `resolve` it may call. It has never heard of a guild.
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
// template, a channel or an address and cannot enumerate them. A module still
// cannot send mail, and this must not become the door that lets it — core
// maps ids to addresses on its own side, after preferences, suppression and
// the verification gate.
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
// contains, and the result is still checked against the trigger's own.
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
// than erroring or silently reaching a different set of people.
//
// All three ceiling at `members`, and none higher. `members` is the lattice value
// for "a module-declared list", and it is the honest one here: these sets are not
// "everyone signed in" narrowed down, they are lists this module happens to know.
//
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
// calls, and every one of them fails to the EMPTY set rather than throwing — a
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
// behaviour and much better than a rule that 500s the engine.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const core = require('../core')
const log = core.logger('shard-audiences')
// One wrapper, so every resolver has the same failure behaviour and none of them
// has to remember it. A resolver that throws would fail the whole enqueue for
// every other audience in the same segment.
const safely = (id, fn) => async (params) => {
try {
return await fn(params || {})
} catch (err) {
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
return []
}
}
const AUDIENCES = [
{
// `namespaced()` requires the module's own prefix, so these are declared with
// it rather than relying on core to add one. Audiences have their own id
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
// `uo.guild.members` here does not collide with any trigger id.
id: 'uo.guild.members',
label: 'Members of a guild',
description: 'Everyone with a linked game account on one guild\'s roster.',
params: [{ id: 'guildId', type: 'int', required: true }],
ceiling: 'members',
resolve: safely('uo.guild.members', async ({ guildId }) => {
if (guildId == null) return []
const accounts = await shardState.listGuildMemberAccounts(guildId)
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.governors',
label: 'Town governors',
description: 'Everyone with a linked game account currently holding a city governorship.',
params: [],
ceiling: 'members',
resolve: safely('uo.governors', async () => {
const accounts = await shardState.listGovernorAccounts()
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.linked.accounts',
label: 'Players with a linked game account',
// The set an operator reaches for first, and — more usefully — the one a
// `not` composes against: "everyone who has NOT linked" is the audience for
// the message that asks them to.
description: 'Every website user who has linked at least one game account.',
params: [],
ceiling: 'members',
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
},
]
module.exports = { AUDIENCES }

View File

@@ -0,0 +1,835 @@
// ── module-uo's engagement triggers ────────────────────────────────────────
//
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
// file declares which shard events a player may get a content-free PUSH tickle
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
// may fire on, what a template may interpolate, and the widest audience an
// operator may ever give it.
//
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
// trigger id and a stream id live in the same space and an id has exactly one
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
// preferences and no push toggle, which is correct — `allStreams()` serves the
// stream facet only, so the shipped Android client's catalog is unchanged.
//
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
//
// • `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 anywhere in the tree.
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
// guild IS a Team and this module is the Team provider, so `teamSync` emits
// on every roster reconcile; a second trigger would be two mails for one join.
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
// • `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 TTL 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 at `subscribers`; "you were pushed out" does not.
//
// **Three rules every declaration below obeys, all of them enforced at
// registration** (`registries.js`), so a mistake here is a boot failure rather
// than a defect discovered in someone's mailbox:
//
// 1. **`ceiling` is required and there is no default.** It is the widest
// audience a rule may ever be given (G24), re-checked at save AND at send.
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
// cheat report to the player who was detected, and `staff` is the answer.
// 2. **Every variable carries an `example`.** It is what the template editor
// previews and test-sends with; without one, testing a template needs a live
// game event, which is how template systems ship untested (§4.3 property 3).
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
// rooted" check while being protocol-relative.
//
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
// wire frame into a call; this file is only the contract. Keeping them apart is
// what lets the declarations be read as a catalogue and diffed against §8.6.
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
// changes, not when one is added — an added optional is what `required: false`
// is for, and a stored rule keeps working across it.
const V1 = 1
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
//
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
// court. A letter is a sentence, and a template has no conditionals by design
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
// leaves a hole mid-clause: "The house , in , stands in peril."
//
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
// declared optional. Two shapes, and each `example` shows which it is —
//
// • a LABEL always has a value, so it can carry a sentence's spine;
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// They are `required: false` and therefore additive: adding one is not a
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
// template written before them keeps working unchanged.
// ── Owned asset at risk — the flagship family ──────────────────────────────
//
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
// a website user, which is what `ownerUserId` on the envelope carries. A house
// or vendor whose owner never linked an account is nobody to notify, and the
// mapper drops it rather than treating it as an error.
const OWNED_ASSET = [
{
id: 'uo.house.idoc_warning',
label: 'Your house is decaying',
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
kind: 'event',
// The house, not the owner. A player with three decaying houses should hear
// about all three; a cooldown keyed on them would report one and swallow the
// rest. This is the case that makes `subjectKey` worth having at all.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
description: 'The stage it was in before.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
// **Protocol 5, and both are `required: false` on purpose.** A shard still
// running a v4 overlay emits no `schedule` at all, and a dynamic-decay shard
// omits `estimatedCollapse` at every stage before IDOC because ServUO draws
// each stage's duration at random when the stage is entered. So the mail has
// to read correctly without them — which is exactly what an optional
// variable and a template that omits an absent one give you.
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
description: 'When the house was last refreshed.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the IDOC page.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
description: 'The decay stage as words rather than as the wire\'s enum.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
],
},
{
id: 'uo.house.collapsed',
label: 'Your house collapsed',
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
kind: 'event',
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it had one.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region it stood in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
],
},
{
// **The good outcome, and it exists because a delay without a cancel is just
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
// `delay_seconds: 900` so an owner who repairs the house inside the window is
// never told it is in peril — and until Phase 11b's live walk there was
// nothing that could cancel it: the mapper returned early on every transition
// that was not a late stage, so a refresh reached the engine as silence. The
// wire already carried the transition; only this declaration was missing.
//
// It is a real notification as well as a cancel signal (decision 11), so it
// carries the labels a body needs rather than the serial alone.
id: 'uo.house.refreshed',
label: 'Your house was refreshed',
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
kind: 'event',
// The SAME subject as the warning it cancels, and that is load-bearing rather
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
// whose subject were anything else would cancel nothing.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject, and what the cancellation matches on.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
description: 'The decay stage it was in before it was refreshed.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the housing page.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
{ name: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
],
},
{
id: 'uo.vendor.expiring',
label: 'Your vendor is about to be dismissed',
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
description: 'The shop\'s name.' },
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
// **The int an operator narrows with**, because `conditions.js` compares a
// declared variable against a LITERAL and has no relative-time operator:
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
// 24h`. So the hours are computed at emit and the operator writes
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
// on any price change.
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
{ name: 'funds', type: 'int', required: false, example: 8204,
description: 'Gold available to pay the fees.' },
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
description: 'Where the shop stands, already formatted for reading.' },
{ name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
description: 'Site-relative path to the market page.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
],
},
]
// ── Passive income ─────────────────────────────────────────────────────────
const PASSIVE_INCOME = [
{
id: 'uo.vendor.sale',
label: 'Your vendor sold something',
// **The tier caveat belongs in the operator-facing text, not only in a
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
// only against ServUO 57.4. A shard that declined the tier emits this kind
// never, so a rule on it is silently dormant rather than broken, and the only
// way an operator finds out is if something says so where they are looking.
description:
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
+ 'declined it never emits this event, and a rule on it stays silent.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
description: 'The vendor that made the sale. Also the cooldown subject.' },
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
description: 'What was sold.' },
{ name: 'amount', type: 'int', required: false, example: 1,
description: 'How many.' },
{ name: 'price', type: 'int', required: true, example: 100,
description: 'What it sold for, in gold.' },
{ name: 'commission', type: 'int', required: false, example: 0,
description: 'Commission taken, on a commission vendor.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
description: 'The whole ledger line, or absent when the sale carried no commission.' },
],
},
]
// ── Personal security ──────────────────────────────────────────────────────
const PERSONAL_SECURITY = [
{
id: 'uo.account.login_failed',
label: 'A failed login to your game account',
description: 'Someone tried to log into your game account and was refused.',
kind: 'event',
// The account, so a burst of attempts against one account is one mail and
// attempts against two accounts are two.
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was tried. Also the cooldown subject.' },
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where the attempt came from.' },
],
},
{
id: 'uo.account.unlinked',
label: 'Your game account was unlinked',
description: 'Someone severed the tie between this game account and your website account, from in game.',
kind: 'event',
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was unlinked. Also the cooldown subject.' },
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
description: 'The character who ran the command.' },
],
},
]
// ── Personal milestone ─────────────────────────────────────────────────────
//
// The two death triggers are a killfeed some players want and most do not.
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
// additionally default their channels `off` rather than relying on the rule
// switch alone.
const PERSONAL_MILESTONE = [
{
id: 'uo.skill.capped',
label: 'You capped a skill',
description: 'One of your characters reached the cap in a skill.',
kind: 'event',
subjectKey: 'skill',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who capped it.' },
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
{ name: 'cap', type: 'float', required: true, example: 100,
description: 'The cap that was reached.' },
],
},
{
id: 'uo.quest.complete',
label: 'You completed a quest',
description: 'One of your characters finished a quest.',
kind: 'event',
subjectKey: 'quest',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who finished it.' },
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
description: 'The quest. Also the cooldown subject.' },
],
},
{
id: 'uo.character.death',
label: 'Your character died',
description: 'One of your characters was killed. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who died. Also the cooldown subject.' },
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
description: 'What killed them, when the shard names it.' },
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
],
},
{
id: 'uo.character.murdered',
label: 'Your character was murdered',
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was murdered. Also the cooldown subject.' },
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
description: 'Who did it, when the shard names them.' },
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
],
},
]
// ── Social / civic ─────────────────────────────────────────────────────────
//
// The two guild triggers ceiling at `members` and resolve through the recipient
// set the emit carries, not through a saved segment: "the members of THIS guild"
// is a different answer for every firing, which a segment's constant params
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
// precedent it was built for.
const SOCIAL_CIVIC = [
{
id: 'uo.guild.left',
label: 'A member left your guild',
description: 'Someone left a guild you are in.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild. Also the cooldown subject.' },
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
// mobile has already left, so the shard has nothing to attribute. The name
// comes from this module's own roster mirror (`shard_guild_members`), and
// is optional because a member the sweep never saw has no row there.
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
description: 'Who left, when the roster mirror still knows their name.' },
{ name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
description: 'Site-relative path to the guilds page.' },
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
],
},
{
id: 'uo.guild.disbanded',
label: 'Your guild disbanded',
description: 'A guild you are in was disbanded or removed.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild that is gone. Also the cooldown subject.' },
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
description: 'Its abbreviation.' },
],
},
{
// **The town's bulletin and the governor's letter are two triggers, not one**
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
// serial and links are keyed by account — and the same reasoning was silently
// assumed to cover this one. It does not: `city.update`'s `governor` field is
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
// `webId`. The new governor is addressable today, with no protocol change.
//
// Widening `uo.governor.elected` to two audiences was the tempting answer and
// was refused: one trigger means one rule means ONE template, and the town's
// announcement and the governor's letter are not the same text. Two also lets
// an operator run the announcement and leave the letter off, or the reverse.
id: 'uo.governor.appointed',
label: 'You were named governor',
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
kind: 'event',
// The city, not the governor: a player who somehow takes two seats in an hour
// should get two letters, and the seat is what the event is about.
subjectKey: 'city',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city whose seat you now hold. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'Your character\'s name, as the city knows it.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
],
},
{
id: 'uo.governor.elected',
label: 'A town elected a governor',
description: 'A city has a new governor.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'The new governor.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
],
},
{
id: 'uo.election.opened',
label: 'Voting opened in a town',
// **The first trigger whose call to action genuinely expires**, which is why
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
// no deadline is a mail nobody acts on, and one delivered after the deadline
// is worse than none. 11b's template says the date, and the seeded rule uses
// no delay for the same reason.
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'phase', type: 'string', required: true, example: 'vote',
description: 'Which phase opened: nominate or vote.' },
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
description: 'When the game decides for itself — the real deadline.' },
// The same instant a person can read. A `datetime` renders as the string the
// payload holds and core has no interpolation filters by design, so a body
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
// The machine value STAYS — an operator writes `is at most` conditions
// against it — and the body uses this one.
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
{ name: 'candidates', type: 'int', required: false, example: 3,
description: 'How many candidates stand.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
description: 'The phase as a clause rather than as the wire\'s enum.' },
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
],
},
]
// ── Come online now ────────────────────────────────────────────────────────
const COME_ONLINE = [
{
id: 'uo.champ.started',
label: 'A champion spawn started',
description: 'A champion spawn became active.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'What is spawning.' },
{ name: 'category', type: 'string', required: false, example: 'champion',
description: 'champion, mini or sea.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
],
},
{
id: 'uo.champ.boss_up',
label: 'A champion boss is up',
description: 'A champion spawn reached its boss.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'The spawn.' },
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
description: 'The boss, when the shard names it.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
],
},
{
id: 'uo.server.up',
label: 'The shard came online',
description: 'The game server started or came back after an outage.',
kind: 'event',
// **No `subjectKey`, and that is the whole point of this pair.** There is one
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
// the shard bounced more than once an hour". Keying it on a boot id would make
// every restart a new subject and every cooldown a no-op, which is precisely
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
// a hard cooldown; this declaration is what makes that cooldown mean anything.
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.server.down',
label: 'The shard went offline',
description: 'The game server shut down or crashed.',
kind: 'event',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true,
description: 'Whether it was a clean shutdown rather than a crash.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
]
// ── Leaderboard ────────────────────────────────────────────────────────────
const LEADERBOARD = [
{
id: 'uo.points.rank_changed',
label: 'A leaderboard top spot changed',
// §8.6 originally described this firing both ways — "you entered a top N" and
// "you were pushed out". The personal half is carved out: `points.board`'s
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
// leaderboard mail that reaches half the board reads as favouritism, so the
// board feed ships and the personal one waits for a serial→account map.
description: 'The top of a leaderboard changed hands.',
kind: 'event',
subjectKey: 'system',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
description: 'The points system. Also the cooldown subject.' },
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
description: 'Its display name, when the shard gives one.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
description: 'Who is first now.' },
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
description: 'Who was first before.' },
{ name: 'points', type: 'int', required: false, example: 29500,
description: 'The new leader\'s points.' },
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
description: 'A label: the board\'s display name, or its system id when it has none.' },
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
],
},
]
// ── Staff-facing ───────────────────────────────────────────────────────────
//
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
// trigger out of a player's preferences catalogue AND gates it on write, so this
// family is the production proof of that work rather than new mechanism.
const STAFF_FACING = [
{
id: 'uo.page.new',
label: 'A player opened a help page',
description: 'A player raised a support ticket in game.',
kind: 'event',
subjectKey: 'pageType',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who raised it.' },
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
description: 'What they wrote.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
description: 'Where they are, already formatted for reading.' },
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
description: 'Site-relative path to the help-page queue.' },
],
},
{
id: 'uo.cheat.detected',
label: 'The cheat detector fired',
description: 'The shard\'s own speed-hack detector flagged a player.',
kind: 'event',
// **`staff`, and never `owner`.** This is the declaration the whole lattice
// was written for: under a flat "fewer people is narrower" ordering a
// `staff` ceiling would also permit `owner`, and the rule an operator would
// then be able to save mails the cheat report to the player who was detected.
subjectKey: 'characterName',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was flagged. Also the cooldown subject.' },
{ name: 'account', type: 'string', required: false, example: 'seed_000',
description: 'Their game account.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where they were connected from.' },
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
description: 'Which detector fired.' },
],
},
]
// ── Operator-facing ────────────────────────────────────────────────────────
//
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
// digest of what moderators did at `staff` would have sent it to the moderators.
// All three are digest-shaped by nature; none should ever be instant, which is a
// property of 11b's seeded rules rather than of these declarations.
const OPERATOR_FACING = [
{
id: 'uo.audit.staff_action',
label: 'A staff member acted in game',
description: 'A staff command, a property change, or a moderation action.',
kind: 'event',
subjectKey: 'staffName',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
{ name: 'action', type: 'string', required: true, example: 'set',
description: 'What kind of action: set, command, ban, kick, mute…' },
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
description: 'The action in one line, already formatted for reading.' },
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who or what it was applied to.' },
{ name: 'origin', type: 'string', required: false, example: 'in-game',
description: 'web or in-game — where the action was issued from.' },
],
},
{
id: 'uo.economy.milestone',
label: 'The economy crossed a threshold',
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
kind: 'event',
subjectKey: 'metric',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'metric', type: 'string', required: true, example: 'gold',
description: 'gold or accounts. Also the cooldown subject.' },
{ name: 'value', type: 'int', required: true, example: 1000000000,
description: 'The value that crossed.' },
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up',
description: 'up or down.' },
{ name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.world.saved',
label: 'The world saved',
description: 'A world save completed, with the item and mobile counts it wrote.',
kind: 'event',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'items', type: 'int', required: false, example: 1482301,
description: 'Items written.' },
{ name: 'mobiles', type: 'int', required: false, example: 41022,
description: 'Mobiles written.' },
],
},
]
const TRIGGERS = [
...OWNED_ASSET,
...PASSIVE_INCOME,
...PERSONAL_SECURITY,
...PERSONAL_MILESTONE,
...SOCIAL_CIVIC,
...COME_ONLINE,
...LEADERBOARD,
...STAFF_FACING,
...OPERATOR_FACING,
]
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
// emit an id this file does not declare, so a typo there is a boot-time-visible
// mistake rather than a dropped event nobody notices.
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
module.exports = {
TRIGGERS,
TRIGGER_IDS,
OWNED_ASSET,
PASSIVE_INCOME,
PERSONAL_SECURITY,
PERSONAL_MILESTONE,
SOCIAL_CIVIC,
COME_ONLINE,
LEADERBOARD,
STAFF_FACING,
OPERATOR_FACING,
}

View File

@@ -93,6 +93,19 @@ module.exports = {
}, },
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) }, auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...args) }, push: { publish: (...args) => need().push.publish(...args) },
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
// event this module DECLARED has happened; the engine decides whether anyone is
// told, on which channel, subject to which rule and preference. `inbox.push`
// writes an in-app item with no rule at all, for the cases that are not events.
//
// Both are fire-and-forget and return undefined by contract — a module calls
// them from inside a game-event handler and there is nothing it could correctly
// do with a storage failure of core's. `inbox.push` additionally does not report
// "the user has this switched off", because a module that could see that would
// be a module that could enumerate people's preferences one write at a time.
events: { emit: (...args) => need().events.emit(...args) },
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: { secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args), encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args), decrypt: (...args) => need().secretBox.decrypt(...args),

View File

@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL, base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL, auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 4, protocol INT NOT NULL DEFAULT 5,
enabled TINYINT(1) NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL, status_detail VARCHAR(500) NULL,
@@ -727,3 +727,62 @@ ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL; ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL; ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`); ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
--
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
-- Two of them land as columns here; the third is a new event kind and needs none.
--
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
-- at and when it was last refreshed, but nothing about WHEN the next thing
-- happens — which is the only part a player can act on. `estimated_collapse` is
-- nullable and stays null far more often than not, deliberately: under dynamic
-- decay (Core.ML) 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. A null here means "not knowable", never "not yet read".
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
-- 2. vendor.listing's owner account and fee state.
--
-- `owner_acct` is the one that matters structurally: the table has carried
-- `owner_name` since Protocol 3, but a character name is not an identity — only
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
-- an owner the site could not resolve to a user.
--
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
-- the charge is compared with the funds and the vendor is destroyed when the
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
-- is what any surface actually wants; the parts are kept alongside it so a
-- display can explain the number rather than only state it.
--
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
-- never dismissed for fees, which is a different thing from having a long time
-- left and must not render as one.
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
-- "vendors about to be dismissed" without scanning every shop, and the owner join
-- that turns one into a person.
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
-- missed an earlier migration is carried the whole way; the one-shot marker is
-- written here in the module's own fragment, because core's schema is replayed in
-- full BEFORE any module fragment and a marker left in core would already exist
-- when this UPDATE read it.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
UPDATE uo_link_config SET protocol = 5
WHERE id = 1 AND protocol < 5
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');

View File

@@ -44,6 +44,9 @@ module.exports = function register(ctx, api) {
const usersShardExtension = require('./router/admin/usersShard.router') const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams') const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const townCrierLeg = require('./utils/shardAnnounce') const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model') const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command') const guildCommand = require('./commands/guild.command')
@@ -88,6 +91,53 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS) api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg) api.registerAnnounceLeg(townCrierLeg.leg)
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
// are PAYLOAD contracts: 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 each one. `uo.cheat.detected` ceilings at `staff`
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
// and core refuses a rule that widens either.
//
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
// registration and the one above are two facets of one space and core enforces
// that an id has exactly one owner across both. None of the ids below reuses a
// stream id: the stream catalog keeps its seven grandfathered names and these
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
// in-app preferences and no push toggle, which is correct — there is nothing to
// push it to, and the shipped Android client's catalog is unchanged.
api.registerEventTriggers(shardTriggers.TRIGGERS)
// Audiences are named sets of PEOPLE an operator composes rules and segments
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
// composition takes the narrowest ceiling it contains, never the widest.
//
// Registration is a claim; nothing resolves until the engine asks, which is
// after `onBoot` — and it must be, because every resolver reads the database
// and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES)
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
// Phase 11b): sixteen in-universe message bodies on two channels each, and
// twenty-five rules — every one of them `enabled = 0`, which the registry
// enforces rather than trusts.
//
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
// that is on still passes the ceiling, the per-user preference, the suppression
// list and the verification gate before anything is sent — all of them core's.
//
// The nine security and operational triggers point at core's generic bodies
// (decision 9). A cheat report should read like a cheat report.
//
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
// fresh installs ONLY. A future trigger wants its own group key.
api.registerEngagementSeeds({
templates: engagementSeeds.TEMPLATES,
ruleGroups: engagementSeeds.RULE_GROUPS,
})
// Teams: a UO guild is a Team, and this module is the authoritative source of // Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions; // them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here. // everything about what a guild IS stays here.
@@ -114,5 +164,7 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version, version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard', routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length, streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
}) })
} }

View File

@@ -39,4 +39,56 @@ const remove = (account, userId) =>
const removeByAccount = (account) => const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account]) query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
// A bound on every "resolve a set of people" read below. It mirrors core's own
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
// cannot reach into core's models, and the number this file has to respect is
// "no more ids than core will accept" whatever core calls it.
const MAX_AUDIENCE = 5000
// **Website user ids for a set of game accounts.** The bulk form of
// `getByAccount`, and the one the engagement mapper needs: a guild event's
// audience is its members, and turning a roster into a set of people is one join
// rather than one query per member (Phase 11).
//
// DISTINCT because two characters on one guild roster can share an account, and
// the caller wants people rather than characters.
async function userIdsForAccounts(accounts) {
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
if (!wanted.length) return []
const capped = wanted.slice(0, MAX_AUDIENCE)
const marks = capped.map(() => '?').join(', ')
const rows = await query(
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
capped,
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
// **Every website user with a linked game account** — the `uo.linked.accounts`
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
// one a `not` composes against ("everyone who has NOT linked").
//
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
// never sees an address, a channel or a template, and core maps ids to addresses
// on its own side after preferences, suppression and the verification gate.
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
const rows = await query(
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
[limit],
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
module.exports = {
upsert,
getByAccount,
listByUser,
isOwnedBy,
remove,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
MAX_AUDIENCE,
}

View File

@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere). // Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account) const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount } // The bulk resolvers the engagement audiences and the guild mapper need
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
// put here, and a module's audience resolver returning ids and nothing else is
// the contract (§5.1a rule 2).
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
module.exports = {
link,
listForUser,
ownsAccount,
getByAccount,
unlink,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
}

View File

@@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) {
await conn.query( await conn.query(
`INSERT INTO shard_vendors `INSERT INTO shard_vendors
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house, (serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
item_count, item_total, truncated, t) item_count, item_total, truncated, t,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) fees_exempt, charge_per_period, funds, pay_interval_sec, next_pay_at,
periods_remaining, dismissal_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial), ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y), owner_name = VALUES(owner_name), owner_acct = VALUES(owner_acct),
map = VALUES(map), x = VALUES(x), y = VALUES(y),
z = VALUES(z), region = VALUES(region), house = VALUES(house), z = VALUES(z), region = VALUES(region), house = VALUES(house),
item_count = VALUES(item_count), item_total = VALUES(item_total), item_count = VALUES(item_count), item_total = VALUES(item_total),
truncated = VALUES(truncated), t = VALUES(t), truncated = VALUES(truncated), t = VALUES(t),
-- Protocol 5. Written back unconditionally, INCLUDING when they are null:
-- a shard downgraded to a pre-v5 overlay stops sending the fees object, and
-- leaving the last v5 values in place would leave a dismissal date standing
-- that nothing is maintaining any more. A stale deadline is worse than none.
fees_exempt = VALUES(fees_exempt), charge_per_period = VALUES(charge_per_period),
funds = VALUES(funds), pay_interval_sec = VALUES(pay_interval_sec),
next_pay_at = VALUES(next_pay_at), periods_remaining = VALUES(periods_remaining),
dismissal_at = VALUES(dismissal_at),
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP: -- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
-- MariaDB does not fire that when every column is written back -- MariaDB does not fire that when every column is written back
-- unchanged, and a shop that is re-published identically is still -- unchanged, and a shop that is re-published identically is still
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
vendor.shopName ?? null, vendor.shopName ?? null,
vendor.ownerSerial ?? null, vendor.ownerSerial ?? null,
vendor.ownerName ?? null, vendor.ownerName ?? null,
vendor.ownerAcct ?? null,
vendor.map ?? null, vendor.map ?? null,
Number.isFinite(vendor.x) ? vendor.x : null, Number.isFinite(vendor.x) ? vendor.x : null,
Number.isFinite(vendor.y) ? vendor.y : null, Number.isFinite(vendor.y) ? vendor.y : null,
@@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) {
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length, Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
vendor.truncated ? 1 : 0, vendor.truncated ? 1 : 0,
Number.isFinite(vendor.t) ? vendor.t : null, Number.isFinite(vendor.t) ? vendor.t : null,
vendor.feesExempt ? 1 : 0,
Number.isFinite(vendor.chargePerPeriod) ? vendor.chargePerPeriod : null,
Number.isFinite(vendor.funds) ? vendor.funds : null,
Number.isFinite(vendor.payIntervalSec) ? vendor.payIntervalSec : null,
vendor.nextPayAt ?? null,
Number.isFinite(vendor.periodsRemaining) ? vendor.periodsRemaining : null,
vendor.dismissalAt ?? null,
], ],
) )

View File

@@ -31,6 +31,7 @@ const MAX_OWNER = 64
const MAX_MAP = 40 const MAX_MAP = 40
const MAX_REGION = 80 const MAX_REGION = 80
const MAX_SERIAL = 20 const MAX_SERIAL = 20
const MAX_ACCT = 120
const clip = (value, max) => { const clip = (value, max) => {
if (value == null) return null if (value == null) return null
@@ -43,6 +44,42 @@ const int = (value, fallback = 0) => {
return Number.isFinite(n) ? Math.trunc(n) : fallback return Number.isFinite(n) ? Math.trunc(n) : fallback
} }
// A wire timestamp -> a Date the DB layer can bind, or null. The shard emits ISO-8601
// (`DateTime.ToString("o")`); anything else is a plugin we do not recognise and is
// dropped rather than stored as an Invalid Date, which MariaDB rejects in strict mode
// and which would fail the whole vendor over one bad field.
const when = (value) => {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
// Protocol 5. The vendor's fee state, normalised out of the frame's `fees` object.
//
// Two things this deliberately does NOT do. It does not recompute `dismissalAt` from
// the parts -- the shard resolved it against ServUO's own two vendor systems (the
// charge, the funds and the interval all differ between them) and re-deriving it here
// would be a second implementation of a rule that lives in PlayerVendor.PayTimer. And
// it does not treat a missing `fees` object as zero: a pre-v5 overlay simply omits it,
// and nulls are how a v5 website says "this shard has not told me" rather than
// "this vendor is broke", which is the difference between silence and a false alarm.
const fees = (f) => {
if (!f || typeof f !== 'object') return { feesExempt: false, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
// A commission vendor has no pay timer and is never dismissed for fees. Reporting it
// as exempt with no schedule is not the same as reporting a very long one, and a
// surface that renders "never" must be able to tell them apart.
if (f.exempt === true) return { feesExempt: true, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
return {
feesExempt: false,
chargePerPeriod: Number.isFinite(f.chargePerPeriod) ? Math.trunc(f.chargePerPeriod) : null,
funds: Number.isFinite(f.funds) ? Math.trunc(f.funds) : null,
payIntervalSec: Number.isFinite(f.payIntervalSec) ? Math.trunc(f.payIntervalSec) : null,
nextPayAt: when(f.nextPayAt),
periodsRemaining: Number.isFinite(f.periodsRemaining) ? Math.trunc(f.periodsRemaining) : null,
dismissalAt: when(f.dismissalAt),
}
}
// ── Ingest ───────────────────────────────────────────────────────────────── // ── Ingest ─────────────────────────────────────────────────────────────────
/** /**
@@ -64,6 +101,10 @@ function flattenFrame(ev) {
shopName: clip(ev.shopName, MAX_SHOP), shopName: clip(ev.shopName, MAX_SHOP),
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL), ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
ownerName: clip(ev.ownerName, MAX_OWNER), ownerName: clip(ev.ownerName, MAX_OWNER),
// Protocol 5. The character name has been here since v3, but only the game
// ACCOUNT joins to shard_account_links -- so this is the field that makes a
// vendor row resolvable to a person at all.
ownerAcct: clip(ev.ownerAcct, MAX_ACCT),
map: clip(loc.map, MAX_MAP), map: clip(loc.map, MAX_MAP),
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null, x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null, y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
@@ -76,6 +117,7 @@ function flattenFrame(ev) {
itemTotal: int(ev.total, int(ev.count, 0)), itemTotal: int(ev.total, int(ev.count, 0)),
truncated: ev.truncated === true, truncated: ev.truncated === true,
t: Number.isFinite(ev.t) ? ev.t : null, t: Number.isFinite(ev.t) ? ev.t : null,
...fees(ev.fees),
} }
} }

View File

@@ -98,7 +98,12 @@ async function latestEconomy() {
// ── Houses / IDOC ──────────────────────────────────────────────────────── // ── Houses / IDOC ────────────────────────────────────────────────────────
const HOUSE_COLS = const HOUSE_COLS =
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' 'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' +
// Protocol 5's decay schedule. Added to the BASE column list rather than to
// HOUSE_REG_COLS because it arrives on house.decay, so a decay-only row -- one the
// registry sweep has never seen -- carries it too, and the public IDOC page reads
// exactly those rows.
', next_stage, estimated_collapse, decay_period_sec, dynamic_decay'
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields) const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
@@ -215,6 +220,26 @@ const listGuildMembers = (guildId) =>
guildId, guildId,
]) ])
// **The game accounts on one guild's roster** — the input to
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
// the authoritative map. A mirror that has drifted would mail the wrong person,
// and a mirror that is behind would mail nobody, so the query that decides who
// is told reads the table whose job that is.
const listGuildMemberAccounts = (guildId) =>
query(
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
[guildId],
)
// The accounts of every sitting governor — the `uo.governors` audience.
// `governor_acct` is NULL on a city with no governor and on one whose governor's
// mobile has no account, and both are simply nobody.
const listGovernorAccounts = () =>
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
// The guild an actor LEADS — matched on the current board (leader_serial or the // The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders // linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it. // is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -391,6 +416,8 @@ module.exports = {
removeGuildMember, removeGuildMember,
clearAllGuildMembers, clearAllGuildMembers,
listGuildMembers, listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
findGuildLedByActor, findGuildLedByActor,
listGuildsLedByAccounts, listGuildsLedByAccounts,
upsertGovernor, upsertGovernor,

View File

@@ -124,10 +124,39 @@ async function upsertHouse(data) {
built_on: data.builtOn ? new Date(data.builtOn) : null, built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0, is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
// Protocol 5. `ownerName` is written back only when the frame carries one, and
// that asymmetry is deliberate: house.update also writes this column, from a
// different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at
// all. Coalescing to null here would let every decay transition ERASE a name the
// registry had already resolved.
...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}),
...decayScheduleFields(data.schedule),
} }
await db.upsertHouse(data.serial, fields) await db.upsertHouse(data.serial, fields)
} }
// Protocol 5's `schedule` object, flattened into its columns.
//
// Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls.
// A schedule is a claim about the future and it goes stale on its own: if a shard is
// rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops
// being knowable, the right stored value is "nothing" rather than the last thing we
// were told. A dated promise nobody is maintaining is worse than no promise.
function decayScheduleFields(schedule) {
const s = schedule && typeof schedule === 'object' ? schedule : {}
const when = (v) => {
if (!v) return null
const d = new Date(v)
return Number.isNaN(d.getTime()) ? null : d
}
return {
next_stage: when(s.nextStage),
estimated_collapse: when(s.estimatedCollapse),
decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null,
dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null,
}
}
function shapeHouse(r) { function shapeHouse(r) {
return { return {
serial: r.serial, serial: r.serial,
@@ -149,6 +178,16 @@ function shapeHouse(r) {
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
builtOn: r.built_on, builtOn: r.built_on,
lastRefreshed: r.last_refreshed, lastRefreshed: r.last_refreshed,
// Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`:
// the visibility projection matches literal JSON keys, so the stored read model
// and the live wire frame have to spell this the same way or the one admin rule
// covers only one of the two paths.
schedule: {
dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay),
nextStage: r.next_stage,
decayPeriodSec: r.decay_period_sec,
estimatedCollapse: r.estimated_collapse,
},
isIdoc: Boolean(r.is_idoc), isIdoc: Boolean(r.is_idoc),
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
@@ -423,6 +462,19 @@ async function listGuildMembers(guildId) {
})) }))
} }
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
// through `shardVisibility`, which strips `acct` for anyone below admin, so
// building an audience out of it would either leak the projection's job into
// this one or silently resolve to nobody depending on who asked. These two go to
// the database for exactly the column they need and pass nothing else on.
const listGuildMemberAccounts = async (guildId) =>
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
const listGovernorAccounts = async () =>
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
function shapeGuild(r) { function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || { return payload || {
@@ -703,6 +755,8 @@ module.exports = {
upsertGuildRoster, upsertGuildRoster,
removeGuildMember, removeGuildMember,
listGuildMembers, listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
replaceGuilds, replaceGuilds,
findGuildForActor, findGuildForActor,
listGuildsLedForAccounts, listGuildsLedForAccounts,

View File

@@ -11,13 +11,16 @@ const { secretBox } = require('../../core')
// Only used before an admin has saved anything — the stored row wins once it exists, // Only used before an admin has saved anything — the stored row wins once it exists,
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar. // and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
// //
// This says 4 because this build handles protocol 4's frames: `guild.roster` and // This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after // vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
// that, which is the bug this constant is now the fix for — a FRESH install pinned //
// 3, the sidecar answered `409 protocol version mismatch` to every REST call, and a // It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
// new deployment read nothing from its shard until an admin edited the number by // this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
// hand in Admin → Shard. See the matching cutover in db/schema.sql. // `409 protocol version mismatch` to every REST call, and a new deployment read nothing
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4 // from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
// matching cutover in db/schema.sql.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
function toSafe(row) { function toSafe(row) {
if (!row) { if (!row) {
@@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
return toSafe(row) return toSafe(row)
} }
module.exports = { getSafe, getWithToken, save, recordStatus } // DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant
// and schema.sql's two declarations of the same number AGREE, rather than asserting a
// hardcoded version at each site -- which is what let them drift apart before.
module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }

View File

@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) }, settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) }, auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) }, push: { publish: spy(Promise.resolve()) },
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
// contract — a module gets no delivery answer back, deliberately — so the
// spies return undefined rather than a promise, which is what core does.
events: { emit: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') }, secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: { middleware: {
requireAuth: (req, res, next) => next(), requireAuth: (req, res, next) => next(),
@@ -97,6 +102,8 @@ function fakeApi() {
legs: [], legs: [],
teamProvider: null, teamProvider: null,
slashCommands: [], slashCommands: [],
triggers: null,
audiences: null,
hooks: {}, hooks: {},
} }
const called = new Set() const called = new Set()
@@ -117,6 +124,16 @@ function fakeApi() {
// takes it: a second call is a module changing its mind halfway through // takes it: a second call is a module changing its mind halfway through
// register(), which core rejects. // register(), which core rejects.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
// the reason above: core stages a registrant's whole batch and applies it as
// one, so a second call is a module changing its mind mid-register().
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
// and merging two calls would make "which group is this rule in" — the
// question the one-shot seed guard answers — unanswerable.
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
} }

View File

@@ -0,0 +1,242 @@
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
//
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
// file proves the content shipped alongside them is coherent — which is a
// different failure mode and a quieter one: a rule pointing at a template key
// that does not exist, or a body built around a variable nothing supplies, is
// invisible until somebody enables the rule and a person does not get a mail.
//
// The three properties worth asserting, none of which a hand run would catch:
//
// 1. **Every rule names a trigger this module declares, and a template that
// exists** — its own or core's nine generic keys.
// 2. **Every LABEL a body builds a sentence around is supplied on every path
// that emits its trigger.** This is the one that earns its keep. The
// fragments are declared `required: false` so a missing one can never
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
// and that leaves nothing at runtime to notice a mapper that forgot one.
// This test is what notices.
// 3. **The plain nine are plain** (decision 9). A security notice drifting
// into the in-universe register is exactly the change nobody would think to
// review, and it is the one with a real cost attached.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const seeds = require('../config/engagementSeeds')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
// property 1). Spelled out rather than imported: this module cannot require core,
// and a key disappearing from core is exactly the breakage worth failing on.
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
const PLAIN = new Set([
'uo.account.login_failed', 'uo.account.unlinked',
'uo.server.up', 'uo.server.down',
'uo.page.new', 'uo.cheat.detected',
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
])
// ── The shape of the set ───────────────────────────────────────────────────
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
const ruled = seeds.RULES.map((r) => r.trigger_id)
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
})
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
for (const r of seeds.RULES) {
// `enabled` is not set here at all — the registry forces 0 — so the
// assertion is that nobody added it. Q3's invariant, at the source.
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
}
})
test('every template key a rule names exists — its own or core\'s', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
for (const r of seeds.RULES) {
for (const [channel, key] of Object.entries(r.template_keys)) {
assert.ok(
own.has(key) || CORE_KEYS.has(key),
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
)
}
}
})
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
let bespoke = 0
for (const r of seeds.RULES) {
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
if (PLAIN.has(r.trigger_id)) {
// **Decision 9, as a check.** A security notice written as a letter is
// indistinguishable in register from the phishing mail it warns about.
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
continue
}
bespoke += 1
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
// Both channels 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.
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
// The DIGEST stays core's. A day of events rolled into a list is not a
// letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 34)
})
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
// is a genuinely confusing pair and the reason this is asserted rather than
// remembered. Caught at registration too, as a boot failure.
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
for (const t of seeds.TEMPLATES) {
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
}
})
test('an email body has a subject and an in-app body has none', () => {
for (const t of seeds.TEMPLATES) {
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
}
})
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
// One prebuilt image mails as any shard. An in-universe body is UO-specific
// and must still be shard-agnostic.
const json = JSON.stringify(seeds.TEMPLATES)
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
}
})
// ── The property the render sweep needed ───────────────────────────────────
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
// trailing ones that may legitimately be empty. A frame that exercises each.
const LABELLED = [
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
['uo.house.collapsed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
['uo.vendor.sale', ['shopLabel', 'itemLine'],
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
// has a hole in it without one. It is `required: false` like the others and
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
// all unless the frame carried `autoPickAt`.
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
['uo.house.refreshed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
]
test('every label a body builds a sentence around is supplied by the mapper', () => {
for (const [triggerId, labels, frame] of LABELLED) {
// A first frame is never a transition, so the upsert kinds need a prior one.
engagement.mapShardEvent(
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
tracker,
)
const targets = engagement.mapShardEvent(frame, tracker)
const target = targets.find((t) => t.triggerId === triggerId)
assert.ok(target, `${triggerId} fired`)
for (const label of labels) {
assert.ok(
target.data[label] !== undefined && target.data[label] !== '',
`${triggerId} supplies ${label} — a body builds a sentence around it`,
)
}
}
})
test('a label is supplied even when every optional field is absent', () => {
// The case the render sweep modelled: a v4 overlay, a house with no name and
// no region. `houseLabel` falls back to the seal number, which is worse prose
// and better than "Be it known that , recorded to thy name".
const target = engagement.mapShardEvent(
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
tracker,
)[0]
assert.match(target.data.houseLabel, /0x40012345/)
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
// The detail line names only what the frame carried — "Recorded at: ." is the
// shape this avoids. The stage is always there, so the line is too; a house
// with no coordinates simply does not get the "Recorded at" half.
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
})
test('a detail line names only the parts the frame actually carried', () => {
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
const at = new Date(Date.now() + 3600_000).toISOString()
const target = engagement.mapShardEvent(
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
tracker,
)[0]
assert.equal(target.triggerId, 'uo.vendor.expiring')
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
})
// ── Trailing fragments ─────────────────────────────────────────────────────
test('a trailing fragment leads with its own space, or is absent entirely', () => {
// `{{slainBy}}.` must close as "has fallen." with no fragment and
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
// its leading space produces "has fallenat the hands of" and nothing would
// notice.
const withKiller = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
tracker,
)[0]
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
const without = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
tracker,
)[0]
assert.equal(without.data.slainBy, undefined)
})
test('every declared fragment carries an example that shows its own shape', () => {
// The `example` is what the template editor previews and test-sends with, so a
// trailing fragment whose example omits the leading space teaches an author the
// wrong thing about where to put one.
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
}
}
})
// ── The group key ──────────────────────────────────────────────────────────
test('one rule group, and appending to it later would reach fresh installs only', () => {
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
// rule has to edit a test whose name says what appending costs.
assert.equal(seeds.RULE_GROUPS.length, 1)
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
})

View File

@@ -144,12 +144,23 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
// sidecar, which 409s every REST call — an install that reads nothing from its // sidecar, which 409s every REST call — an install that reads nothing from its
// shard, with the cause only in the log. These tests are the guard. // shard, with the cause only in the log. These tests are the guard.
// The protocol this build speaks, read from the model rather than written here.
//
// Hardcoding the number in this test is what the protocol-4 bug looked like from the
// other side: the emitters moved, one declaration site did not, and every site agreed
// with itself. Reading DEFAULT_PROTOCOL makes the assertion "the three declarations
// AGREE" rather than "they all say 4", so a bump that misses one of them fails here
// instead of on an operator's install.
const { DEFAULT_PROTOCOL } = require('../model/uoLinkConfig/uoLinkConfig.model')
test('the column default pins the protocol this build speaks', () => { test('the column default pins the protocol this build speaks', () => {
assert.ok(Number.isInteger(DEFAULT_PROTOCOL) && DEFAULT_PROTOCOL > 0, 'no protocol pin exported')
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s)) const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
assert.ok(create, 'uo_link_config is gone') assert.ok(create, 'uo_link_config is gone')
assert.match( assert.match(
create, create,
/protocol\s+INT\s+NOT NULL DEFAULT 4/i, new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
'the CREATE TABLE default must name the protocol this build speaks', 'the CREATE TABLE default must name the protocol this build speaks',
) )
@@ -159,7 +170,34 @@ test('the column default pins the protocol this build speaks', () => {
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s), /^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
) )
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone') assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
assert.match(modifies[modifies.length - 1], /DEFAULT 4/i) assert.match(
modifies[modifies.length - 1],
new RegExp('DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
)
})
// The one-shot migration for the CURRENT protocol, whatever it is. Same argument as
// above: these three assertions used to be written once per version by hand, so the
// version that mattered — the newest — was the one with no test until someone
// remembered to copy the block.
test('the current protocol has a one-shot migration, correctly ordered and guarded', () => {
const marker = `uo_link_protocol_${DEFAULT_PROTOCOL}_migrated`
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes(marker),
)
const insert = statements.findIndex((s) => /^INSERT/i.test(s) && s.includes(`'${marker}'`))
assert.ok(update >= 0, `no migration to protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert >= 0, `no one-shot marker for protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert > update, 'the marker is written before the UPDATE reads it')
// `protocol < N`, never `= N-1`: an install that missed an earlier migration has to
// be carried the whole way rather than one step.
assert.match(
statements[update],
new RegExp('protocol *< *' + DEFAULT_PROTOCOL + '(?![0-9])'),
)
}) })
test('the protocol-4 marker is written AFTER the update that reads it', () => { test('the protocol-4 marker is written AFTER the update that reads it', () => {

View File

@@ -0,0 +1,645 @@
// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ─────
//
// Two halves, tested separately for the reason the file splits them: `mapShardEvent`
// is pure given a tracker and needs no database, and `fromShardEvent` is the half
// that resolves an account into a person and therefore does.
//
// What is asserted here is deliberately not "each field is copied". It is the
// three things a rule cannot express and a plain mapping would get wrong —
// transitions, thresholds, and who an event is ABOUT — plus the four places §8.6
// or the protocol docs say the obvious implementation is the wrong one.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS } = require('../config/clientPaths')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const map = (event) => engagement.mapShardEvent(event, tracker)
const ids = (event) => map(event).map((t) => t.triggerId)
const one = (event) => {
const out = map(event)
assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`)
return out[0]
}
// ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
assert.equal(TRIGGERS.length, 26)
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
// assertion is the guard on the carve-outs: adding one back is a decision, and
// a decision should have to edit a test that says so.
for (const carved of [
'uo.market.item_listed', // a saved SEARCH; no per-user query store exists
'uo.guild.joined', // core's team.member.joined already fires for it
'uo.link.requested', // no addressable recipient, and a ~5-minute TTL
]) {
assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`)
}
// Every id is this module's, which is what `namespaced()` enforces at
// registration — asserted here too so the failure names the id rather than
// arriving as a boot error.
for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`)
})
test('every variable carries an example, because a template is previewed with it', () => {
for (const t of TRIGGERS) {
for (const v of t.variables) {
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
assert.ok(v.description, `${t.id}.${v.name} has a description`)
}
// A subjectKey that is not one of the trigger's own variables is refused at
// registration; catching it here names the trigger instead of the boot.
if (t.subjectKey) {
assert.ok(
t.variables.some((v) => v.name === t.subjectKey),
`${t.id} subjectKey "${t.subjectKey}" is one of its variables`,
)
}
}
})
test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => {
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`)
// Not protocol-relative: `//evil.test/x` passes an "is it rooted" check.
assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-relative`)
}
}
})
test('a url example names a route this module actually mounts', () => {
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
// `mounts` — and the client router prefixes a module's routes with its **ID**
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
// over: the example is what the template editor previews and test-sends with,
// and `clientPaths.js` is now the single place both it and the bodies read.
const known = new Set(Object.values(PATHS))
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
const parent = v.example.replace(/\/[^/]+$/, '')
assert.ok(
known.has(v.example) || known.has(parent),
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
)
}
}
})
test('every url variable a body can interpolate is actually SUPPLIED', () => {
// The defect this exists for is invisible in the source and invisible in a
// fixture: a declared-but-never-populated optional interpolates to the empty
// string, so the letter renders perfectly and its call-to-action button has no
// href. Nine of the sixteen in-universe bodies shipped that way.
//
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
// added is covered the day it is declared.
const frames = {
'uo.house.idoc_warning': DECAY,
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
'uo.vendor.expiring': listing(FEES(20)),
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
// Two frames each: an upsert kind is never a transition on FIRST sight, so
// the tracker has to see a baseline before the change means anything.
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
'uo.server.down': { kind: 'server.shutdown' },
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
}
for (const t of TRIGGERS) {
const urls = t.variables.filter((v) => v.type === 'url')
if (!urls.length) continue
const frame = frames[t.id]
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
const fresh = engagement.createTracker()
let target = null
for (const f of Array.isArray(frame) ? frame : [frame]) {
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
if (hit) target = hit
}
assert.ok(target, `${t.id} did not fire for its frame`)
for (const v of urls) {
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-relative`)
}
}
})
// The declaration that the whole ceiling lattice exists for.
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
assert.equal(cheat.ceiling, 'staff')
assert.equal(cheat.audience, 'staff')
// The three operator-facing ones sit a rung lower still: `staff` means admin,
// editor AND moderator, so a digest of what moderators did must not ceiling there.
for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) {
assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`)
}
})
// ── Houses ─────────────────────────────────────────────────────────────────
const DECAY = {
kind: 'house.decay',
serial: '0x400142F9',
from: 'Fairly',
to: 'Greatly',
name: 'Millrace',
ownerAcct: 'seed_002',
region: 'Britain',
map: 'Felucca',
x: 1480,
y: 1600,
lastRefreshed: '2026-08-25T17:21:14Z',
}
test('a late decay stage warns the owner; an early one says nothing', () => {
const t = one(DECAY)
assert.equal(t.triggerId, 'uo.house.idoc_warning')
assert.equal(t.ownerAccount, 'seed_002')
assert.equal(t.data.stage, 'Greatly')
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
// not news, and mailing it would make the warning worthless.
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
})
test('a refresh is its own trigger, and it is what cancels the warning', () => {
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
// the decay sweep has always emitted this transition.
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
assert.equal(t.triggerId, 'uo.house.refreshed')
assert.equal(t.ownerAccount, 'seed_002')
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
// (rule, subject_key), so a different one would cancel nothing.
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
assert.equal(t.data.previousStage, 'Greatly')
// A TRAILING fragment: its own leading space, and empty rather than reading
// "It stood in decay." when the previous stage has no word of its own.
assert.equal(t.data.fromLine, ' It stood greatly worn.')
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
})
test('the v5 schedule rides along when present and is simply absent when not', () => {
const withSchedule = one({
...DECAY,
schedule: {
dynamicDecay: true,
nextStage: '2026-09-01T20:33:15Z',
estimatedCollapse: '2026-09-06T20:33:15Z',
},
})
assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z')
// **A dynamic-decay shard omits `estimatedCollapse` at every stage before
// IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit
// that absence means "not knowable", never "not yet read" — so the mapper must
// pass the absence through rather than computing a fallback, which would
// republish exactly the guess the shard refused to make.
const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } })
assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal('estimatedCollapse' in dynamic.data, false)
const v4 = one(DECAY)
assert.equal('nextStage' in v4.data, false)
assert.equal('estimatedCollapse' in v4.data, false)
})
test('Collapsed is its own trigger, not a louder warning', () => {
const t = one({ ...DECAY, to: 'Collapsed' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, 'seed_002')
})
test('house.remove carries only a serial, so the owner is looked up later', () => {
const t = one({ kind: 'house.remove', serial: '0x400142F9' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, undefined)
assert.equal(t.houseSerial, '0x400142F9')
})
// ── Vendors: the threshold, and the two ways there is nothing to warn about ──
const listing = (fees) => ({
kind: 'vendor.listing',
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerAcct: 'darrow_acct',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
...(fees === undefined ? {} : { fees }),
})
const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString()
const FEES = (h) => ({
exempt: false,
newVendorSystem: true,
chargePerPeriod: 10548,
funds: 8204,
payIntervalSec: 86400,
periodsRemaining: 1,
dismissalAt: inHours(h),
})
test('a vendor entering the warning window fires ONCE, not on every sweep frame', () => {
// `vendor.listing` is re-emitted on any price change, so without the crossing
// check a vendor inside the window mails its owner every time somebody
// reprices a longsword.
// 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
// number the answer is 20 or 19 depending on whether a millisecond has passed
// since the fixture was built, and this assertion was flaking on exactly that.
const first = one(listing(FEES(20.5)))
assert.equal(first.triggerId, 'uo.vendor.expiring')
assert.equal(first.ownerAccount, 'darrow_acct')
assert.equal(first.data.hoursRemaining, 20)
assert.deepEqual(ids(listing(FEES(19))), [])
assert.deepEqual(ids(listing(FEES(18))), [])
})
test('a deposit that leaves the window re-arms the warning', () => {
assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring'])
assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window
assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in
})
test('exempt and absent fees are both "nothing to warn about", not "no money"', () => {
// A commission vendor has no PayTimer and is NEVER dismissed for fees.
// Conflating that with a distant date is how a vendor that cannot expire ends
// up in an expiry warning (docs/link/v5.md).
assert.deepEqual(ids(listing({ exempt: true })), [])
// A pre-v5 overlay sends no `fees` block at all.
assert.deepEqual(ids(listing(undefined)), [])
})
test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => {
const t = one(listing(FEES(-3)))
assert.equal(t.data.hoursRemaining, 0)
})
test('an unowned listing is nobody to notify', () => {
const { ownerAcct, ...anonymous } = listing(FEES(10))
assert.deepEqual(ids(anonymous), [])
})
// ── Logins: the inversion protocol 5 exists to fix ─────────────────────────
test('only a FAILED login warns — a successful one produces nothing', () => {
const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' })
assert.equal(failed.triggerId, 'uo.account.login_failed')
assert.equal(failed.data.reason, 'BadPass')
assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), [])
})
test('the pre-decision attempt kind is not mapped at all', () => {
// `account.login.attempt` fires from a sink that runs BEFORE the auth decision
// and whose args default `Accepted = true`, so a rule on it would have mailed a
// security alert on every successful login. That is why v5 added a second kind
// and why this one must stay unmapped.
assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), [])
})
// ── Transitions ────────────────────────────────────────────────────────────
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
assert.deepEqual(ids(champ({ active: true })), [])
assert.deepEqual(ids(champ({ active: true })), []) // still no change
assert.deepEqual(ids(champ({ active: false })), [])
assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started'])
})
test('the boss is its own transition, tracked separately from active', () => {
map(champ({ active: true, bossUp: false }))
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
assert.deepEqual(ids(champ({ active: true, bossUp: true })), [])
})
test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => {
map(champ({ active: false }))
map({ kind: 'champ.remove', serial: '0x40012345' })
assert.deepEqual(ids(champ({ active: true })), [])
})
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
test('a governor change is a transition, and never on first sight', () => {
assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), [])
const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } }))
assert.equal(t.triggerId, 'uo.governor.elected')
assert.equal(t.data.governorName, 'Darrow')
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
})
test('an ELECTED governor with a linked account also gets a letter', () => {
// Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a
// person because `top[]` names a serial — and the same reasoning was silently
// assumed to cover the governor. It does not: `BridgeJson.Actor()` writes
// `acct` on every actor object, so the winner is addressable with no protocol
// change. This test is the record of that, and of the decision that the
// announcement and the letter are TWO triggers.
map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } }))
const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } }))
assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed'])
const letter = out[1]
assert.equal(letter.ownerAccount, 'darrow')
assert.equal(letter.data.city, 'Britain')
assert.equal(letter.data.governorName, 'Darrow')
// The bulletin carries no owner — it is the town's, not the governor's.
assert.equal(out[0].ownerAccount, undefined)
})
test('an UNLINKED governor still gets the town its announcement', () => {
// Nobody to write to is an ordinary outcome, not an error — most game accounts
// on most shards have never been linked — and it must not cost the city its
// proclamation.
map(city({ governor: { serial: '0x1', name: 'Mireille' } }))
assert.deepEqual(
ids(city({ governor: { serial: '0x2', name: 'Darrow' } })),
['uo.governor.elected'],
)
})
test('an election opening needs its deadline, or it does not fire', () => {
map(city({ electionPhase: 'none' }))
// **A "vote now" mail with nothing to act by is worse than none**, and
// `autoPickAt` is declared required, so a phase change without one is dropped
// here rather than refused by `emit` later.
assert.deepEqual(ids(city({ electionPhase: 'vote' })), [])
const fresh = engagement.createTracker()
engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh)
const out = engagement.mapShardEvent(
city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }),
fresh,
)
assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened'])
assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z')
})
// ── The shard's own up/down, which is the cooldown table's stress test ─────
test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => {
// `server.hello` is sent on EVERY sidecar reconnect, not only on a shard
// restart, which is exactly the flapping this trigger must not amplify.
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up'])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), [])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), [])
})
test('down fires once per outage, and a crash is told apart from a clean stop', () => {
map({ kind: 'server.hello', shard: 'UOMysticmoon' })
const down = one({ kind: 'server.shutdown' })
assert.equal(down.triggerId, 'uo.server.down')
assert.equal(down.data.clean, true)
assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down
map({ kind: 'server.hello' })
assert.equal(one({ kind: 'server.crashed' }).data.clean, false)
})
// ── Thresholds ─────────────────────────────────────────────────────────────
const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts })
test('an economy milestone fires on a crossing, in both directions, never on first sight', () => {
// A sidecar reconnect on a mature shard must not announce a line it crossed
// months ago.
assert.deepEqual(ids(supply(900_000_000)), [])
const up = one(supply(1_200_000_000))
assert.equal(up.triggerId, 'uo.economy.milestone')
assert.equal(up.data.direction, 'up')
assert.equal(up.data.threshold, 1_000_000_000)
assert.deepEqual(ids(supply(1_300_000_000)), []) // same band
const down = one(supply(800_000_000))
assert.equal(down.data.direction, 'down')
assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through
})
// ── Leaderboards ───────────────────────────────────────────────────────────
const board = (serial, name) => ({
kind: 'points.board',
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }],
})
test('a leaderboard change names the new leader and nobody personally', () => {
assert.deepEqual(ids(board('0x1A2B', 'Darrow')), [])
const t = one(board('0x1A2C', 'Bran'))
assert.equal(t.triggerId, 'uo.points.rank_changed')
assert.equal(t.data.leaderName, 'Bran')
// The personal half is carved out: `top[]` names a mobile SERIAL and links are
// keyed by ACCOUNT, so there is deliberately no owner on this target.
assert.equal(t.ownerAccount, undefined)
assert.deepEqual(ids(board('0x1A2C', 'Bran')), [])
})
// ── Milestones ─────────────────────────────────────────────────────────────
test('only a capped skill is a milestone', () => {
const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), [])
const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 })
assert.equal(t.triggerId, 'uo.skill.capped')
assert.equal(t.ownerAccount, 'seed_000')
// A mobile with no account is nobody's character.
assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), [])
})
test('both deaths address the victim, never the killer', () => {
const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' }
const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } })
assert.equal(death.ownerAccount, 'seed_000')
assert.equal(death.data.killerName, 'an ogre lord')
const murder = one({ kind: 'player.murdered', victim, murderer })
assert.equal(murder.triggerId, 'uo.character.murdered')
assert.equal(murder.ownerAccount, 'seed_000')
assert.equal(murder.data.murdererName, 'Darrow')
})
// ── Guilds ─────────────────────────────────────────────────────────────────
test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => {
const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' })
assert.equal(left.triggerId, 'uo.guild.left')
assert.equal(left.guildId, 1042)
assert.equal(left.memberSerial, '0x77')
assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded')
// Core's `team.member.joined` already fires for this, on every roster
// reconcile, because a UO guild IS a Team and this module is the provider.
// A second trigger would be two mails for one join (§8.6).
assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), [])
})
// ── Staff and operator ─────────────────────────────────────────────────────
test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => {
const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 })
assert.equal(page.triggerId, 'uo.page.new')
assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's
const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' })
assert.equal(cheat.triggerId, 'uo.cheat.detected')
assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected
assert.equal(cheat.data.account, 'seed_000') // but staff are told which account
})
test('the three audit kinds fold into one operator trigger', () => {
assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action'])
assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action'])
const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' })
assert.equal(admin.data.action, 'ban')
assert.equal(admin.data.origin, 'web')
})
test('world.save.after reports what it wrote', () => {
const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 })
assert.equal(t.triggerId, 'uo.world.saved')
assert.equal(t.data.items, 1482301)
// `before` is a boundary, not news.
assert.deepEqual(ids({ kind: 'world.save.before' }), [])
})
// ── The guard ──────────────────────────────────────────────────────────────
test('an unmapped kind and a malformed frame both produce nothing', () => {
assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), [])
assert.deepEqual(ids({ kind: 'region.enter' }), [])
assert.deepEqual(engagement.mapShardEvent(null, tracker), [])
assert.deepEqual(engagement.mapShardEvent({}, tracker), [])
assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), [])
})
// ── Resolution: the half that reaches the database ─────────────────────────
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
// one, taken FROM that model rather than written out here: the model's `toSafe`
// camel-cases the row, and a hand-written fake using the column names is a fake
// that will agree with a resolver reading the column names. Stubbing the db
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
function linkRow(account, userId) {
const realGet = shardLinksDb.getByAccount
shardLinksDb.getByAccount = async () => ({
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
})
try {
return shardLinksModel.getByAccount(account)
} finally {
shardLinksDb.getByAccount = realGet
}
}
function deps(over = {}) {
const emitted = []
return {
emitted,
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
tracker,
shardLinks: {
// Shaped by the REAL model's `toSafe`, not by the column names. A fake that
// returns `user_id` agrees with a resolver that reads `user_id`, and the
// pair passes while every owner-audienced trigger reaches nobody on a live
// shard — which is exactly what happened. `linkRow` below is the guard.
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 7) : null),
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
...over.shardLinks,
},
shardState: {
listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }],
listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }],
listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }],
listGuildMemberAccounts: async () => ['seed_002'],
...over.shardState,
},
}
}
test('an owner-keyed event resolves the game account to a website user', async () => {
const d = deps()
await engagement.fromShardEvent(DECAY, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning')
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
})
test('an UNLINKED owner is nobody to notify, and that is not an error', async () => {
// The common case on every shard: most game accounts have never been linked.
const d = deps()
await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d)
assert.deepEqual(d.emitted, [])
})
test('house.remove fills the owner and the name in from the registry mirror', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace')
})
test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => {
// §5.1a: "the members of THIS guild" is a different answer every firing, so a
// saved segment cannot express it and the set travels on the envelope
// (Phase 6, decision 2 — the mechanism the Team fan-out was built on).
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d)
assert.equal(d.emitted.length, 1)
assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9])
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
// The two names the frames do not carry come from the mirrors.
assert.equal(d.emitted[0].envelope.data.memberName, 'Bran')
})
test('guild.remove names the guild from the board, because the frame carries only an id', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d)
assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand')
assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH')
})
test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => {
const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } })
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d)
assert.deepEqual(d.emitted, [])
})
test('a subscribers-shaped event needs no resolution at all', async () => {
const d = deps()
engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition
await engagement.fromShardEvent(champ({ active: true }), d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
assert.equal(d.emitted[0].envelope.recipientUserIds, undefined)
})
test('a failing lookup costs that one target and never the ingest feed', async () => {
const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } })
await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d))
assert.deepEqual(d.emitted, [])
})

View File

@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
const found = await clilocs.resolveMany([1023721]) const found = await clilocs.resolveMany([1023721])
assert.equal(found.get(1023721), 'quarter staff') assert.equal(found.get(1023721), 'quarter staff')
}) })
// ── Protocol 5: owner account and fee state ────────────────────────────────
const V5_FEES = {
exempt: false,
newVendorSystem: true,
chargePerPeriod: 148,
funds: 2960,
holdGold: 2960,
bankAccount: 0,
payIntervalSec: 86400,
nextPayAt: '2026-09-01T00:00:00.000Z',
periodsRemaining: 20,
dismissalAt: '2026-09-21T00:00:00.000Z',
}
test('flattenFrame lifts ownerAcct, the field that makes a shop resolvable to a person', () => {
// ownerName has been on the frame since v3, but a character name joins to nothing:
// shard_account_links is keyed by the game ACCOUNT.
const row = market.flattenFrame({ ...FRAME, ownerAcct: 'darrow_acct', fees: V5_FEES })
assert.equal(row.ownerAcct, 'darrow_acct')
assert.equal(row.ownerName, 'Darrow', 'the character name is still carried too')
})
test('flattenFrame normalises the fee block, dates included', () => {
const row = market.flattenFrame({ ...FRAME, fees: V5_FEES })
assert.equal(row.feesExempt, false)
assert.equal(row.chargePerPeriod, 148)
assert.equal(row.funds, 2960)
assert.equal(row.payIntervalSec, 86400)
assert.equal(row.periodsRemaining, 20)
assert.ok(row.nextPayAt instanceof Date)
assert.equal(row.dismissalAt.toISOString(), '2026-09-21T00:00:00.000Z')
})
// The shard resolved dismissalAt against ServUO's two vendor systems, whose charge,
// funds and pay interval all differ. Re-deriving it here would be a second
// implementation of a rule that lives in PlayerVendor.PayTimer.
test('flattenFrame trusts the shard dismissal date instead of recomputing it', () => {
const row = market.flattenFrame({
...FRAME,
fees: { ...V5_FEES, dismissalAt: '2026-12-25T00:00:00.000Z' },
})
assert.equal(row.dismissalAt.toISOString(), '2026-12-25T00:00:00.000Z')
})
// A commission vendor has no pay timer and is never dismissed for fees. That is a
// different thing from having a long time left, and a surface rendering "never" has
// to be able to tell them apart.
test('an exempt vendor reports exempt with no schedule at all', () => {
const row = market.flattenFrame({ ...FRAME, fees: { exempt: true } })
assert.equal(row.feesExempt, true)
assert.equal(row.dismissalAt, null)
assert.equal(row.periodsRemaining, null)
assert.equal(row.chargePerPeriod, null)
})
// A pre-v5 overlay omits `fees` entirely, and a shard can be rolled back to one.
// Nulls have to mean "this shard has not told me", never "this vendor is broke" —
// the difference between silence and a false alarm in a rule that mails an owner.
test('a pre-v5 frame yields nulls, not zeroes', () => {
const row = market.flattenFrame(FRAME)
assert.equal(row.feesExempt, false)
for (const key of ['chargePerPeriod', 'funds', 'payIntervalSec', 'periodsRemaining']) {
assert.equal(row[key], null, `${key} must be null, not 0`)
}
assert.equal(row.nextPayAt, null)
assert.equal(row.dismissalAt, null)
assert.equal(row.ownerAcct, null)
})
test('an unparseable fee date is dropped rather than stored as an Invalid Date', () => {
const row = market.flattenFrame({
...FRAME,
fees: { ...V5_FEES, dismissalAt: 'next tuesday', nextPayAt: null },
})
assert.equal(row.dismissalAt, null)
assert.equal(row.nextPayAt, null)
assert.equal(row.funds, 2960, 'one bad field must not discard the rest of the block')
})
test('a malformed fees value is treated as absent, not as a crash', () => {
for (const fees of ['', 0, 'nope', []]) {
const row = market.flattenFrame({ ...FRAME, fees })
assert.equal(row.feesExempt, false)
assert.equal(row.dismissalAt, null)
}
})

View File

@@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps
assert.equal(typeof out[0].startedAt, 'number') assert.equal(typeof out[0].startedAt, 'number')
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0 assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
}) })
// ── Protocol 5: the decay schedule ─────────────────────────────────────────
test('upsertHouse flattens the nested schedule into its four columns', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.000Z',
},
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.dynamic_decay, 1)
assert.equal(fields.decay_period_sec, 432000)
assert.ok(fields.next_stage instanceof Date)
assert.equal(fields.estimated_collapse.toISOString(), '2026-09-02T04:00:00.000Z')
})
// The whole point of the field: under dynamic decay ServUO draws each stage's
// duration at random on entry, so the shard omits estimatedCollapse everywhere but
// IDOC. A stored null has to mean "not knowable", which it cannot if a partial
// schedule silently keeps the previous value.
test('a schedule without a collapse time stores null, it does not keep the old one', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'Greatly',
schedule: { dynamicDecay: true, nextStage: '2026-09-01T00:00:00.000Z', decayPeriodSec: 432000 },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.estimated_collapse, null)
assert.ok('estimated_collapse' in fields, 'must be WRITTEN as null, not omitted')
})
// A pre-v5 overlay sends no schedule at all, and a shard can be rolled back to one.
// Every column is still written, so a dismissal date nobody is maintaining cannot
// be left standing.
test('a frame with no schedule nulls all four columns rather than omitting them', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'Fairly' })
const [, fields] = calls.upsertHouse[0]
for (const col of ['next_stage', 'estimated_collapse', 'decay_period_sec', 'dynamic_decay']) {
assert.ok(col in fields, `${col} must be written`)
assert.equal(fields[col], null)
}
})
test('an unparseable schedule date is dropped, not stored as an Invalid Date', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: { nextStage: 'soon-ish', estimatedCollapse: '' },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.next_stage, null)
assert.equal(fields.estimated_collapse, null)
})
// house.update writes owner_name from its own sweep. If house.decay coalesced a
// missing ownerName to null, every decay transition on a pre-v5 shard would erase
// a name the registry had already resolved.
test('house.decay never erases an owner_name it was not given', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerAcct: 'cadmus' })
const [, fields] = calls.upsertHouse[0]
assert.ok(!('owner_name' in fields), 'owner_name must not be written when absent')
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerName: 'Cadmus' })
const [, withName] = calls.upsertHouse[1]
assert.equal(withName.owner_name, 'Cadmus')
})

View File

@@ -476,3 +476,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
visibility.forgetUser(6) visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in') assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
}) })
// ── Protocol 5 ─────────────────────────────────────────────────────────────
//
// Two new nested field groups and one new kind. All three exist as visibility
// questions before they exist as features, which is the order this framework's
// rule 2 is designed to force: a v5 field that nobody classified would either
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
// noticed). These tests pin the three answers that were actually chosen.
test('a vendor fee block is admin-only, and it is the whole block', async () => {
const config = await visibility.getConfig()
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
const frame = {
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerName: 'Darrow',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
fees: {
exempt: false,
chargePerPeriod: 148,
funds: 2960,
periodsRemaining: 20,
dismissalAt: '2026-09-20T00:00:00.0000000Z',
},
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('market', frame, level, config)
assert.equal('fees' in out, false, `fees reached ${level}`)
// The rest of the shop is untouched — this is a field rule, not a feature one.
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
}
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
assert.equal(asAdmin.fees.funds, 2960)
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
})
// The nesting is the point, not a style choice: projectValue matches literal JSON
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
// and a v6 field would default to visible. One nested key cannot drift.
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
const config = await visibility.getConfig()
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
const out = visibility.projectFeature('market', frame, 'staff', config)
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
})
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
// page's entire content, and a house at IDOC is already announced in game.
test('the decay schedule is anonymous by default but remains configurable', async () => {
const frame = {
serial: '0x1',
to: 'IDOC',
name: 'Marble Tower',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.0000000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
},
}
const config = await visibility.getConfig()
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
// A shard that considers a precise collapse time an unfair advantage can raise it,
// and raising the one nested rule takes the whole schedule with it.
withRows([
{
feature: 'houses',
enabled: true,
audience: 'anonymous',
stream: true,
fieldRules: { schedule: 'staff' },
},
])
const tightened = await visibility.getConfig()
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
assert.equal(
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
432000,
)
// Tightening the schedule must not have disturbed the owner rules beside it.
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
})
// Rule 2, exercised on the kind it was added for. account.login.result says whether
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
// omission is the decision. If someone maps it to a feature to "make it visible",
// this fails and says why.
test('account.login.result is admin-only, like the attempt it completes', async () => {
const config = await visibility.getConfig()
assert.equal(
visibility.KIND_FEATURE.has('account.login.result'),
false,
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
)
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
}
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
})

View File

@@ -0,0 +1,987 @@
// ── Shard event → engagement trigger ───────────────────────────────────────
//
// ENGAGEMENT.md Phase 11. The third fan-out off `shardIngest.ingest`, beside the
// SSE broadcast and the push tickle, and the one that produces a PER-PERSON
// notification subject to a rule, a preference and a suppression. It is the twin
// of `shardPush.js` and reads deliberately like it:
//
// • `shardStreams.mapShardEvent` turns a frame into push targets;
// `mapShardEvent` here turns a frame into engagement events.
// • Owner resolution is why neither can be a pure mapper: an owner-keyed target
// names a GAME ACCOUNT, and turning that into a website user needs
// `shardLinks`. An unlinked account is simply nobody to notify.
//
// **Nothing here decides who is told.** It says what happened and (for an
// owner- or members-shaped event) who it is ABOUT; the engine applies the rules,
// the ceiling, the preferences and the suppression list. That split is the
// module boundary: a module cannot send mail (§1.2) and this is not the back door.
//
// **Never throws, never blocks ingest.** `ingest()` calls this fire-and-forget
// exactly as it calls the broadcast and the push dispatch, and every mapper below
// is wrapped so one bad frame cannot stop the feed. This is the same reason the
// C# side's `Emit()` enqueues and returns rather than touching the socket from the
// Core thread.
//
// ── Three things that are NOT a plain field mapping ────────────────────────
//
// Most of §8.6's rows are "read four fields off the frame and emit". Three are
// not, and each is here rather than in a rule because a rule cannot express it:
//
// 1. **Transitions.** `champ.update` and `city.update` are full-state UPSERTS
// re-emitted on any change, not discrete "started"/"elected" events. Without
// a per-process transition tracker, a reconnect snapshot is read as twenty
// champion spawns starting at once. `shardStreams.js` already solved this for
// push and this file uses the same shape — and the same rule that a FIRST
// sighting is never a transition.
// 2. **Thresholds.** `uo.vendor.expiring` and `uo.economy.milestone` fire when a
// value CROSSES a line. `conditions.js` compares a declared variable against
// a literal and has no relative-time or previous-value operator, so
// "within 24 hours of dismissal" and "gold passed a billion" are not
// expressible as conditions — and `vendor.listing` is a sweep frame
// re-emitted on every price change, so emitting per frame would flood. The
// crossing is tracked here; the operator still narrows with
// `hoursRemaining is at most N`.
// 3. **Audience resolution for `members`.** A guild event is about the members
// of THAT guild, which is a different answer for every firing and therefore
// cannot be a saved segment (whose params are constants). The access-checked
// set travels on the envelope as `recipientUserIds` — Phase 6's decision 2,
// and the mechanism the Team fan-out was built on.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const { TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS, guildPath } = require('../config/clientPaths')
const core = require('../core')
const log = core.logger('shard-engagement')
// ── Thresholds ─────────────────────────────────────────────────────────────
// When a vendor becomes "expiring". Hours rather than pay periods, because a pay
// period is a real day under the new vendor system and a UO day (~2 real hours)
// under the old one — the exact factor-of-twelve trap `docs/link/v5.md` records,
// and the reason the wire carries `dismissalAt` as an instant.
//
// 48 hours is one full real day of warning even on a shard whose owner logs in
// daily, and it is the OUTER edge: the mapper fires once on the way in, and the
// operator narrows further with `hoursRemaining is at most 24` if they want less.
const VENDOR_WARN_HOURS = 48
// Gold-supply reporting lines, ascending. Crossing one in either direction is one
// `uo.economy.milestone`. They are the module's rather than the operator's for
// now: an admin-configurable ladder is a settings surface, and this phase's job
// is the trigger. An operator who wants a different line writes a rule condition
// on `value`.
const GOLD_THRESHOLDS = [
100_000_000, 250_000_000, 500_000_000, 1_000_000_000,
2_500_000_000, 5_000_000_000, 10_000_000_000,
]
// The same, for account count.
const ACCOUNT_THRESHOLDS = [100, 250, 500, 1000, 2500, 5000, 10_000]
// Which line a value sits above, as an index. -1 means "below the first".
const bandOf = (value, thresholds) => {
let band = -1
for (let i = 0; i < thresholds.length; i += 1) if (value >= thresholds[i]) band = i
return band
}
// ── The transition tracker ─────────────────────────────────────────────────
/**
* Per-process state for the upsert kinds and the threshold kinds.
*
* Injectable so a test gets a fresh one; a module-level default backs the live
* dispatcher. It is deliberately NOT persisted: its whole job is to say "has
* this process seen a previous value", and a value restored from a database
* would make the first frame after a restart a transition against state the
* shard may have left behind hours ago.
*/
function createTracker() {
return {
champActive: new Map(), // spawn serial → boolean
champBossUp: new Map(), // spawn serial → boolean
cityGovernor: new Map(), // city → governor serial or null
cityPhase: new Map(), // city → electionPhase
vendorWarned: new Map(), // vendor serial → boolean (already inside the window)
pointsLeader: new Map(), // points system → leader serial
economyBand: new Map(), // metric → band index
serverUp: null, // boolean or null (never seen)
}
}
const defaultTracker = createTracker()
/** Reset the module-level tracker. For tests and for `shardIngest.reset()`. */
function reset() {
const fresh = createTracker()
for (const key of Object.keys(fresh)) defaultTracker[key] = fresh[key]
}
// ── Small shared shapes ────────────────────────────────────────────────────
// "Felucca 1480, 1600" — one string rather than four variables, because a
// template that has to assemble coordinates is a template every author gets
// slightly differently. Returns undefined when there is nothing to format, so it
// drops out of an optional variable rather than rendering "undefined , ".
function place(ev) {
const map = ev.map || (ev.location && ev.location.map)
const x = ev.x ?? (ev.location && ev.location.x)
const y = ev.y ?? (ev.location && ev.location.y)
if (!map && x == null) return undefined
const coords = x == null || y == null ? '' : ` ${x}, ${y}`
const region = ev.region || (ev.location && ev.location.region)
const suffix = region ? ` (${region})` : ''
return `${map || ''}${coords}${suffix}`.trim() || undefined
}
// An actor object's display name, whichever of the shard's shapes it arrives in.
const actorName = (actor) => (actor && typeof actor === 'object' ? actor.name : undefined) || undefined
const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct : undefined) || undefined
// Drop the undefined values before they reach `emit`. A declared OPTIONAL
// variable that arrives as `undefined` is dropped by `validatePayload` anyway,
// but building the object without them keeps the emit log's `variables` list
// honest about what the frame actually carried.
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
// ── Presentational fragments (ENGAGEMENT.md Phase 11b, decision 8) ─────────
//
// **A template has no conditionals, by design** (`interpolate.js`: no filters,
// no loops, no ternaries), and an unset optional interpolates to the EMPTY
// STRING. That is exactly right for `notify.event`, whose variables are
// structural — but the in-universe bodies are sentences, and a sentence with a
// hole in the middle of it reads as a bug: "The house , in , stands in peril."
//
// So the ternary stays at the call site and its RESULT arrives as a declared
// optional variable, which is Phase 5a's `forWhom` precedent unchanged. Two
// shapes, and the difference matters when you write one:
//
// • a LABEL always has a value, so it can carry a sentence's spine
// (`houseLabel` is a name, or a seal number when there is no name);
// • a TRAILING FRAGMENT may be empty and leads with its own space, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// Every one of them is declared `required: false` on the trigger with an
// `example` showing precisely what it produces, leading space included — which
// is what the template editor previews and test-sends with.
/** A trailing fragment, or undefined when there is nothing to say. */
const trailing = (value, build) => (value ? build(value) : undefined)
// "The Silver Anvil, in Britain" · "the house under seal 0x40001234". A house
// often has no name and sometimes no region, and the warning has to name
// SOMETHING the owner can act on — a seal number is worse prose and better than
// a blank.
const houseLabel = (name, region, serial) => {
const named = name ? `${name}` : `the house under seal ${serial}`
return region ? `${named}, in ${region}` : named
}
// The decay stages as words rather than as the wire's enum. `Greatly` in the
// middle of a sentence is the shard's vocabulary leaking into a letter.
const STAGE_WORDS = {
FAIRLY: 'fairly worn',
GREATLY: 'greatly worn',
IDOC: 'in imminent danger of collapse',
}
const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] || 'in decay'
// The election phases likewise: `nominate` and `vote` are wire values.
// A whole DETAIL LINE, assembled from the parts that are actually present.
//
// The same argument `place()` above makes, one level up: a template that has to
// assemble four optional numbers into a sentence is a template every author gets
// slightly differently, and one whose optionals are absent renders
// "On hand: gold. Charged each period: gold." — which is what the render sweep
// found on a pre-v5 vendor frame. Passing the assembled line means the template
// interpolates ONE variable and the empty case is empty rather than punctuated.
// A wire instant as a person reads it: "2 September 2026, 04:06 UTC".
//
// Core deliberately has no interpolation filters (`interpolate.js` — no ternaries,
// no formatters), so a `datetime` variable renders as whatever string the payload
// holds — and the wire's is an ISO-8601 stamp with seven decimal places, which is
// what a letter from the Merchants' Guild was signing off with. Same argument as
// `place()` and `detailLine()` one line down: the presentation is assembled here,
// at the call site, and arrives as its own value.
//
// **The machine value is never replaced.** `dismissalAt` and `autoPickAt` are
// declared `datetime` and an operator can write `is at most` conditions against
// them (`conditions.js`), so the readable form is an ADDITIONAL variable and the
// ISO one stays exactly as it was.
const readableTime = (iso) => {
if (!iso) return undefined
const at = new Date(iso)
if (Number.isNaN(at.getTime())) return undefined
const day = at.getUTCDate()
const month = MONTHS[at.getUTCMonth()]
const hh = String(at.getUTCHours()).padStart(2, '0')
const mm = String(at.getUTCMinutes()).padStart(2, '0')
return `${day} ${month} ${at.getUTCFullYear()}, ${hh}:${mm} UTC`
}
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const detailLine = (parts) => {
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
}
const PHASE_WORDS = { nominate: 'Nominations are open', vote: 'The ballot is open' }
const phaseLabel = (phase) => PHASE_WORDS[String(phase || '')] || 'The election has moved'
// ── The mappers ────────────────────────────────────────────────────────────
//
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
// dedupeKey? }`. Resolution — account → user id, guild → member ids — happens in
// `dispatch` below, because it needs the database and these must not.
//
// `ownerAccount` is the same field name `shardStreams.js` uses for the same idea,
// so the two mappers can be read side by side.
const decayName = (ev) => ev.name || undefined
const MAPPERS = {
// ── Owned asset at risk ────────────────────────────────────────────────
'house.decay': (ev, tracker, out) => {
const to = String(ev.to || '').toUpperCase()
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial) return
// COLLAPSED is its own trigger; the late stages are the warning. `LikeNew`
// and the early stages are not news — a house being refreshed is the normal
// case and mailing it would make the warning worthless.
if (to === 'COLLAPSED') {
out.push({
triggerId: 'uo.house.collapsed',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseName: decayName(ev),
region: ev.region || undefined,
location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
whereLine: detailLine([['Last recorded at', place(ev)]]),
}),
})
return
}
// **The good outcome** (Phase 11b decision 11). A house refreshed back to
// LikeNew is what `uo.house.idoc_warning`'s 900-second delay exists to give
// the owner time to do, and until this branch the refresh reached the engine
// as silence — so the delay was a late mail rather than a cancellable one.
// Nothing on the wire changed: the decay sweep has always emitted this
// transition, and the early return below was swallowing it.
// **`AGELESS` as well as `LIKENEW`, and the first is the commoner case.**
// ServUO reports `LikeNew` for a house that is still on a decay clock and
// has just been refreshed (`DecayType.ManualRefresh`), and `Ageless` for one
// that is no longer on a clock at all — which is what the owner's newest
// house becomes the moment they log back in, because `DecayType` flips to
// `AutoRefresh` and the getter stops advancing the stage. A returning player
// is the ordinary way a decaying house is rescued, so reading only `LikeNew`
// would miss most rescues. Both mean "out of danger", which is what this
// trigger says.
if (to === 'LIKENEW' || to === 'AGELESS') {
out.push({
triggerId: 'uo.house.refreshed',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
previousStage: ev.from || undefined,
region: ev.region || undefined,
location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
// A TRAILING FRAGMENT, so it leads with its own space and the sentence
// closes cleanly without it. Built only for a stage that has a word —
// "It stood in decay." is the fallback label leaking into prose, and an
// empty fragment reads better than that.
fromLine: trailing(
STAGE_WORDS[String(ev.from || '').toUpperCase()] ? ev.from : null,
(stage) => ` It stood ${stageLabel(stage)}.`,
),
}),
})
return
}
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
out.push({
triggerId: 'uo.house.idoc_warning',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
stage: ev.to,
houseLabel: houseLabel(decayName(ev), ev.region, serial),
stageLabel: stageLabel(ev.to),
whereLine: detailLine([['Recorded at', place(ev)], ['Stage entered', ev.to]]),
previousStage: ev.from || undefined,
region: ev.region || undefined,
location: place(ev),
// **Both optional, and both genuinely absent much of the time.** A v4
// overlay sends no `schedule` at all; a dynamic-decay shard omits
// `estimatedCollapse` at every stage before IDOC because ServUO draws
// each stage's duration at random when the stage is entered. Passing
// `undefined` through is the honest thing — `docs/link/v5.md` is explicit
// that absence means "not knowable", never "not yet read", and computing
// a fallback here would republish exactly the guess the shard refused to.
nextStage: schedule.nextStage || undefined,
estimatedCollapse: schedule.estimatedCollapse || undefined,
lastRefreshed: ev.lastRefreshed || undefined,
}),
})
},
// `house.remove` carries ONLY a serial — the house is gone, so the frame has
// nothing else to say. The owner comes from this module's own registry mirror,
// which is a database read and therefore happens in `dispatch`.
'house.remove': (ev, tracker, out) => {
if (ev.serial == null) return
out.push({
triggerId: 'uo.house.collapsed',
houseSerial: String(ev.serial),
data: { houseSerial: String(ev.serial) },
})
},
'vendor.listing': (ev, tracker, out) => {
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial || !ev.ownerAcct) return
const fees = ev.fees && typeof ev.fees === 'object' ? ev.fees : null
// A pre-v5 overlay sends no `fees`; a commission vendor sends `{exempt:true}`
// and is NEVER dismissed for them. Both mean "nothing to warn about", and
// conflating exempt with a distant date is how a vendor that cannot expire
// ends up in an expiry warning (`docs/link/v5.md`).
if (!fees || fees.exempt === true || !fees.dismissalAt) {
tracker.vendorWarned.delete(serial)
return
}
const at = new Date(fees.dismissalAt)
if (Number.isNaN(at.getTime())) return
const hours = Math.floor((at.getTime() - Date.now()) / 3_600_000)
const inWindow = hours <= VENDOR_WARN_HOURS
const wasWarned = tracker.vendorWarned.get(serial) === true
tracker.vendorWarned.set(serial, inWindow)
// **Only the CROSSING.** The sweep re-emits a shop on any price change, so
// without this a vendor inside the window mails its owner every time somebody
// reprices a longsword. Leaving the window (a deposit) clears the flag above,
// so the next approach warns again — which is the behaviour an owner wants.
if (!inWindow || wasWarned) return
out.push({
triggerId: 'uo.vendor.expiring',
ownerAccount: ev.ownerAcct,
data: defined({
vendorSerial: serial,
marketUrl: PATHS.market,
shopName: ev.shopName || undefined,
shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
dismissalAt: fees.dismissalAt,
// Never negative: a vendor already past its dismissal tick is being
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
hoursRemaining: Math.max(0, hours),
periodsRemaining: Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined,
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
location: place(ev),
ledgerLine: detailLine([
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
['Dismissal', readableTime(fees.dismissalAt)],
['Standing at', place(ev)],
]),
}),
})
},
'vendor.listing.remove': (ev, tracker) => {
if (ev.serial != null) tracker.vendorWarned.delete(String(ev.serial))
},
// ── Passive income ─────────────────────────────────────────────────────
'vendor.sale': (ev, tracker, out) => {
if (!ev.ownerAcct) return
out.push({
triggerId: 'uo.vendor.sale',
ownerAccount: ev.ownerAcct,
data: defined({
vendorSerial: String(ev.vendorSerial ?? ''),
itemName: ev.itemType || 'an item',
// "3 × Iron Ingot" or just "Iron Ingot" — `amount` is optional and a
// sentence reading "sold Iron Ingot" is the hole this closes.
itemLine: Number.isFinite(ev.amount) && ev.amount > 1
? `${ev.amount} × ${ev.itemType || 'an item'}`
: (ev.itemType || 'an item'),
shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
price: Number.isFinite(ev.price) ? ev.price : 0,
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
ledgerLine: detailLine([
['Commission withheld', Number.isFinite(ev.commission) ? `${ev.commission} gold` : undefined],
]),
}),
})
},
// ── Personal security ──────────────────────────────────────────────────
//
// **`account.login.result` and NOT `account.login.attempt`.** The attempt fires
// from `EventSink.AccountLogin`, which runs before the auth decision — the
// emitter's own comment says so — and `AccountLoginEventArgs` constructs with
// `Accepted = true`, so a rule on it would have mailed a security alert every
// time the player logged in successfully. That inversion is why protocol 5 adds
// this kind and why the trigger is named `login_failed` rather than `attempt`.
'account.login.result': (ev, tracker, out) => {
if (!ev.acct) return
if (ev.accepted !== false) return
out.push({
triggerId: 'uo.account.login_failed',
ownerAccount: ev.acct,
data: defined({
account: String(ev.acct),
reason: ev.reason || undefined,
ip: ev.ip || undefined,
}),
})
},
// **Resolved BEFORE ingest drops the link mirror**, which is the whole reason
// this file is called from `ingest()` ahead of the state write rather than
// after it. `applyStateChange` removes the `shard_account_links` row for this
// account, so an owner lookup that ran afterwards would find nobody and the one
// person who needs to know their account was unlinked would never be told.
'account.unlinked': (ev, tracker, out) => {
if (!ev.account) return
out.push({
triggerId: 'uo.account.unlinked',
ownerAccount: ev.account,
data: defined({
account: String(ev.account),
characterName: ev.char || undefined,
}),
})
},
// ── Personal milestone ─────────────────────────────────────────────────
'skill.gain': (ev, tracker, out) => {
// The cap, and only the cap. `skill.gain` fires on every tenth of a point;
// `base >= cap` is the milestone and everything else is noise.
if (!Number.isFinite(ev.base) || !Number.isFinite(ev.cap) || ev.base < ev.cap) return
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.skill.capped',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
skill: String(ev.skill || 'a skill'),
cap: ev.cap,
}),
})
},
'quest.complete': (ev, tracker, out) => {
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.quest.complete',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
quest: String(ev.quest || 'a quest'),
}),
})
},
'player.death': (ev, tracker, out) => {
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.character.death',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
killerName: actorName(ev.killer),
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
}),
})
},
'player.murdered': (ev, tracker, out) => {
const acct = actorAcct(ev.victim)
if (!acct) return
out.push({
triggerId: 'uo.character.murdered',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.victim) || 'your character',
murdererName: actorName(ev.murderer),
slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`),
}),
})
},
// ── Social / civic ─────────────────────────────────────────────────────
//
// `uo.guild.joined` is NOT here: core's `team.member.joined` already fires for
// it on every roster reconcile, because a UO guild is a Team and this module is
// the Team provider. See ENGAGEMENT.md §8.6 for the carve-out.
'guild.leave': (ev, tracker, out) => {
if (ev.id == null) return
out.push({
triggerId: 'uo.guild.left',
guildId: ev.id,
// `who` is a bare SERIAL string here, not an actor object — the mobile has
// already left, so there is nothing for the shard to attribute. The name is
// looked up from the roster mirror in `dispatch`.
memberSerial: ev.who == null ? null : String(ev.who),
data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
})
},
'guild.remove': (ev, tracker, out) => {
if (ev.id == null) return
out.push({
triggerId: 'uo.guild.disbanded',
guildId: ev.id,
// The frame carries ONLY the id, so the name comes from the board mirror in
// `dispatch` — and it has to be read there before `applyStateChange` drops
// the row, the same ordering `account.unlinked` depends on.
data: {},
})
},
'city.update': (ev, tracker, out) => {
const { city } = ev
if (!city) return
// A new governor. Never on FIRST sight (`prev === undefined`), so a reconnect
// snapshot is not read as eight simultaneous elections.
const gov = ev.governor && ev.governor.serial != null ? String(ev.governor.serial) : null
const prevGov = tracker.cityGovernor.get(city)
tracker.cityGovernor.set(city, gov)
if (prevGov !== undefined && gov && gov !== prevGov) {
const governorName = actorName(ev.governor) || 'a new governor'
// **The frame carries no previous holder by name.** The tracker holds the
// outgoing governor's SERIAL and nothing maps a serial to a name here, so
// the succession fragment is empty today and the declaration is optional.
// It is declared rather than omitted so the body does not have to be
// rewritten the day `city.update` gains a `previousGovernor` actor.
const civic = defined({
city: String(city),
governorsUrl: PATHS.governors,
governorName,
previousGovernorName: undefined,
inSuccessionTo: undefined,
})
out.push({ triggerId: 'uo.governor.elected', data: civic })
// **And the letter to the person who won** (Phase 11b, decision 10). Same
// frame, same transition, same never-on-first-sight guard — a different
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
// every actor object it emits, so this needs no protocol change; an
// unlinked governor is simply nobody to write to, which `resolveTarget`
// already treats as an ordinary outcome rather than an error.
const acct = actorAcct(ev.governor)
if (acct) {
out.push({
triggerId: 'uo.governor.appointed',
ownerAccount: acct,
data: { ...civic },
})
}
}
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
// change that arrives without one is not emitted at all rather than emitted
// as a deadline-less call to action — which is what a "vote now" mail with
// nothing to act by would be.
const phase = ev.electionPhase || 'none'
const prevPhase = tracker.cityPhase.get(city)
tracker.cityPhase.set(city, phase)
if (
prevPhase !== undefined
&& phase !== prevPhase
&& (phase === 'nominate' || phase === 'vote')
&& ev.autoPickAt
) {
out.push({
triggerId: 'uo.election.opened',
data: defined({
city: String(city),
governorsUrl: PATHS.governors,
phase,
phaseLabel: phaseLabel(phase),
autoPickAt: ev.autoPickAt,
autoPickWhen: readableTime(ev.autoPickAt),
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
candidateNote: trailing(
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
(n) => (n === 1 ? ' One candidate stands.' : ` ${n} candidates stand.`),
),
}),
})
}
},
// ── Come online now ────────────────────────────────────────────────────
'champ.update': (ev, tracker, out) => {
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial) return
const isActive = ev.active === true
const wasActive = tracker.champActive.get(serial)
tracker.champActive.set(serial, isActive)
const base = defined({
spawnSerial: serial,
champsUrl: PATHS.champs,
spawnName: ev.name || ev.type || 'a champion spawn',
category: ev.category || undefined,
location: place(ev),
atPlace: trailing(place(ev), (p) => ` at ${p}`),
})
if (wasActive !== undefined && isActive && wasActive !== true) {
out.push({ triggerId: 'uo.champ.started', data: base })
}
const bossUp = ev.bossUp === true
const wasBossUp = tracker.champBossUp.get(serial)
tracker.champBossUp.set(serial, bossUp)
if (wasBossUp !== undefined && bossUp && wasBossUp !== true) {
out.push({
triggerId: 'uo.champ.boss_up',
data: defined({ ...base, bossName: ev.boss || undefined }),
})
}
},
'champ.remove': (ev, tracker) => {
if (ev.serial == null) return
tracker.champActive.delete(String(ev.serial))
tracker.champBossUp.delete(String(ev.serial))
},
// **`server.hello` fires on every sidecar reconnect, not only on a shard
// restart** — which is exactly the flapping this trigger must not amplify. The
// tracker's `serverUp` is the guard: a hello while we already believe the shard
// is up is a reconnect and emits nothing. The seeded rule's hard cooldown is the
// second line of defence, for a shard genuinely bouncing.
'server.hello': (ev, tracker, out) => {
const wasUp = tracker.serverUp
tracker.serverUp = true
if (wasUp === true) return
out.push({
triggerId: 'uo.server.up',
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
})
},
'server.shutdown': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
},
'server.crashed': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
},
// ── Leaderboard ────────────────────────────────────────────────────────
//
// `subscribers` only. `top[]` names a mobile SERIAL and `shard_account_links`
// is keyed by game ACCOUNT, so the "you were pushed out" half of §8.6's row is
// carved out rather than resolved for whoever happens to be online.
'points.board': (ev, tracker, out) => {
const system = ev.system
const top = Array.isArray(ev.top) ? ev.top : []
if (!system || !top.length) return
const leader = top.find((e) => e && e.rank === 1) || top[0]
if (!leader || leader.serial == null) return
const serial = String(leader.serial)
const prev = tracker.pointsLeader.get(system)
tracker.pointsLeader.set(system, serial)
if (prev === undefined || prev === serial) return
out.push({
triggerId: 'uo.points.rank_changed',
data: defined({
system: String(system),
systemName: ev.nameString || undefined,
leaderName: leader.name || 'a new leader',
// The board frame carries no previous holder — the tracker holds only a
// SERIAL, and a serial is not a name — so this is the one family whose
// trailing fragment is always empty today. Declared anyway, because the
// alternative is a body that has to be rewritten when the frame gains it.
boardLabel: ev.nameString || String(system),
points: Number.isFinite(leader.points) ? leader.points : undefined,
standingLine: Number.isFinite(leader.points)
? `${leader.name || 'a new leader'} now stands first upon it, with ${leader.points} to their name.`
: `${leader.name || 'a new leader'} now stands first upon it.`,
}),
})
},
// ── Staff-facing ───────────────────────────────────────────────────────
'page.new': (ev, tracker, out) => {
out.push({
triggerId: 'uo.page.new',
data: defined({
pagesUrl: PATHS.ops,
pageType: String(ev.type || 'Other'),
senderName: actorName(ev.sender),
message: ev.message || undefined,
location: place(ev),
}),
})
},
'cheat.fastwalk': (ev, tracker, out) => {
out.push({
triggerId: 'uo.cheat.detected',
data: defined({
characterName: actorName(ev.who) || 'an unnamed character',
account: actorAcct(ev.who),
ip: ev.ip || undefined,
detector: 'fastwalk',
}),
})
},
// ── Operator-facing ────────────────────────────────────────────────────
'audit.set': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
action: 'set',
detail: ev.prop ? `${ev.prop}: ${ev.old ?? '?'}${ev.new ?? '?'}` : undefined,
target: ev.target || undefined,
origin: 'in-game',
}),
})
},
'audit.command': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
action: 'command',
detail: ev.command ? `${ev.command} ${ev.args || ''}`.trim() : undefined,
origin: 'in-game',
}),
})
},
'admin.audit': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: typeof ev.actor === 'string' ? ev.actor : actorName(ev.actor),
action: String(ev.action || 'action'),
detail: ev.reason || undefined,
target: ev.target || undefined,
origin: ev.origin || undefined,
}),
})
},
'economy.supply': (ev, tracker, out) => {
for (const [metric, value, thresholds] of [
['gold', ev.gold, GOLD_THRESHOLDS],
['accounts', ev.accounts, ACCOUNT_THRESHOLDS],
]) {
if (!Number.isFinite(value)) continue
const band = bandOf(value, thresholds)
const prev = tracker.economyBand.get(metric)
tracker.economyBand.set(metric, band)
// First sighting establishes the band and reports nothing. Otherwise a
// sidecar reconnect on a mature shard announces "gold passed a billion"
// about a line it crossed months ago.
if (prev === undefined || prev === band) continue
// The line that was crossed is the HIGHER of the two bands under a rise and
// the one just left under a fall, so both directions name the line the
// reader is thinking about.
const crossed = band > prev ? thresholds[band] : thresholds[prev]
out.push({
triggerId: 'uo.economy.milestone',
data: defined({
economyUrl: PATHS.shard,
metric,
value: Math.round(value),
threshold: crossed,
direction: band > prev ? 'up' : 'down',
}),
})
}
},
'world.save.after': (ev, tracker, out) => {
out.push({
triggerId: 'uo.world.saved',
data: defined({
items: Number.isFinite(ev.items) ? ev.items : undefined,
mobiles: Number.isFinite(ev.mobiles) ? ev.mobiles : undefined,
}),
})
},
}
/**
* Map one shard event to zero or more engagement events. Pure given `tracker`.
*
* Exported so the mapping can be tested without a database, exactly as
* `shardStreams.mapShardEvent` is.
*/
function mapShardEvent(event, tracker = defaultTracker) {
if (!event || typeof event.kind !== 'string') return []
const mapper = MAPPERS[event.kind]
if (!mapper) return []
const out = []
mapper(event, tracker, out)
// **Defence in depth, and the exact counterpart of `shardStreams.js`'s
// public-allowlist filter.** A target naming an id this module does not declare
// cannot be delivered — `emit` would refuse it anyway, throwing in dev and
// logging in prod — so catching it here turns a typo into one warning with the
// id in it rather than an exception on the ingest path.
return out.filter((t) => {
if (TRIGGER_IDS.has(t.triggerId)) return true
log.warn('mapper produced an undeclared trigger id', { kind: event.kind, triggerId: t.triggerId })
return false
})
}
// ── Resolution and dispatch ────────────────────────────────────────────────
/**
* Turn one mapped target into the envelope `ctx.events.emit` takes, or null when
* there is nobody to tell.
*
* This is the half that reaches the database, and it is why the mapping above is
* separate: an owner-keyed target names a GAME ACCOUNT and a members-keyed one
* names a GUILD, and neither is a website user until something asks.
*/
async function resolveTarget(target, deps) {
const { links, state } = deps
const data = { ...target.data }
// `owner` — one account, one user. An unlinked account is nobody to notify,
// which is a normal outcome and not an error: most game accounts on most shards
// have never been linked.
//
// **`userId`, not `user_id`.** The model's `toSafe` camel-cases the row on the
// way out, so reading the column name silently makes EVERY owner-audienced
// trigger resolve to nobody — indistinguishable, from here and from the logs,
// from the ordinary unlinked-account case above. `shardPush.js` is the
// precedent this file follows and it reads `owner.userId`.
if (target.ownerAccount) {
const link = await links.getByAccount(target.ownerAccount)
if (!link || link.userId == null) return null
return { data, ownerUserId: Number(link.userId) }
}
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
// The owner comes from this module's registry mirror — and this read has to
// happen before `applyStateChange` drops the row, which is why ingest calls the
// engagement fan-out ahead of the state write.
if (target.houseSerial) {
const houses = await state.listHouses()
const house = houses.find((h) => String(h.serial) === target.houseSerial)
if (!house || !house.ownerAcct) return null
const link = await links.getByAccount(house.ownerAcct)
if (!link || link.userId == null) return null
if (house.name) data.houseName = house.name
if (house.region) data.region = house.region
return { data, ownerUserId: Number(link.userId) }
}
// `members` — the guild's roster, resolved to website users through
// `shard_account_links` rather than through the roster's mirrored `web_id`.
// The mirror is a copy of what the wire said; the links table is the answer.
if (target.guildId != null) {
const accounts = await state.listGuildMemberAccounts(target.guildId)
const userIds = await links.userIdsForAccounts(accounts)
if (!userIds.length) return null
// Fill in the two names the frames do not carry, from the board mirror.
if (!data.guildName || !data.abbreviation) {
const guilds = await state.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(target.guildId))
if (guild) {
if (!data.guildName) data.guildName = guild.name || `guild ${target.guildId}`
if (guild.abbr && data.abbreviation === undefined) data.abbreviation = guild.abbr
}
}
if (!data.guildName) data.guildName = `guild ${target.guildId}`
// Who left, from the roster mirror — the departing member's row is still
// there, because `guild.leave`'s state write has not run yet.
if (target.memberSerial) {
const members = await state.listGuildMembers(target.guildId)
const gone = members.find((m) => String(m.serial) === target.memberSerial)
if (gone && gone.name) data.memberName = gone.name
// The in-universe body's spine (Phase 11b decision 8). Built HERE and not
// in the mapper because the name comes from the roster mirror, which the
// mapper cannot read — and a herald's notice that names nobody is worse
// than one that says "a member".
if (data.guildName !== undefined) data.memberLabel = data.memberName || 'A member'
}
return { data, recipientUserIds: userIds }
}
// Everything else — `subscribers`, `staff`, `admin` — has no per-event
// audience to resolve. The rule's audience is the whole answer.
return { data }
}
/**
* Fan one shard event out to the engagement engine.
*
* Never throws. Called fire-and-forget from `shardIngest.ingest`, beside the SSE
* broadcast and the push dispatch, and held to the same promise all three make:
* a slow or failing notification path must never delay or fail ingest.
*/
async function fromShardEvent(event, deps = {}) {
const d = {
links: deps.shardLinks || shardLinks,
state: deps.shardState || shardState,
emit: deps.emit || core.events.emit,
tracker: deps.tracker || defaultTracker,
}
for (const target of mapShardEvent(event, d.tracker)) {
try {
const resolved = await resolveTarget(target, d)
// Nobody to tell. Not an error and deliberately not logged at warn: an
// unlinked house owner is the common case on every shard.
if (!resolved) continue
d.emit(target.triggerId, {
data: resolved.data,
...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}),
...(resolved.recipientUserIds ? { recipientUserIds: resolved.recipientUserIds } : {}),
...(target.dedupeKey ? { dedupeKey: target.dedupeKey } : {}),
occurredAt: Number.isFinite(event.t) ? new Date(event.t) : undefined,
})
} catch (err) {
log.warn('engagement target failed', { triggerId: target.triggerId, message: err.message })
}
}
}
module.exports = {
fromShardEvent,
mapShardEvent,
createTracker,
reset,
VENDOR_WARN_HOURS,
GOLD_THRESHOLDS,
ACCOUNT_THRESHOLDS,
}

View File

@@ -20,6 +20,7 @@ const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const { settings: settingsModel } = require('../core') const { settings: settingsModel } = require('../core')
const broadcaster = require('./shardBroadcast') const broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush') const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
const defaultLog = require('../core').logger('shard-ingest') const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds // Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -62,6 +63,11 @@ const LOGGED_KINDS = new Set([
const state = { bootId: null } const state = { bootId: null }
function reset() { function reset() {
state.bootId = null state.bootId = null
// The engagement mapper's transition/threshold tracker is per-process state of
// exactly the same kind as `bootId`, so it is reset by the same call. A test
// that reset one and not the other would see a champion spawn that started in
// the previous test.
shardEngagement.reset()
} }
// Should this event be written to the append-only log? // Should this event be written to the append-only log?
@@ -169,8 +175,14 @@ async function applyStateChange(event, deps) {
name: event.name, name: event.name,
ownerSerial: event.ownerSerial, ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct, ownerAcct: event.ownerAcct,
// Protocol 5. `ownerName` used to arrive only on house.update, so a house
// that had decayed but never been swept into the registry named an account
// and no character. It rides house.decay now, which is the frame the IDOC
// page is actually built from.
ownerName: event.ownerName,
builtOn: event.builtOn, builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed, lastRefreshed: event.lastRefreshed,
schedule: event.schedule,
}) })
return return
case 'champ.update': case 'champ.update':
@@ -279,6 +291,7 @@ function resolveDeps(deps) {
settings: deps.settings || settingsModel, settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast, broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent, pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
engagement: deps.engagement || shardEngagement.fromShardEvent,
log: deps.log || defaultLog, log: deps.log || defaultLog,
} }
} }
@@ -294,6 +307,34 @@ async function ingest(event, deps = {}) {
let stored = false let stored = false
let logged = false let logged = false
// **The engagement fan-out runs BEFORE the state write, and that ordering is
// load-bearing rather than incidental** (ENGAGEMENT.md Phase 11). Three of the
// mappings read a row that `applyStateChange` is about to delete or replace:
//
// • `account.unlinked` drops the `shard_account_links` row — the row that
// turns the account into the one person who needs to be told it was
// unlinked. Resolving afterwards finds nobody, every time.
// • `house.remove` drops the house, whose stored `ownerAcct` is the only place
// the owner of a collapsed house is named (the frame carries a serial alone).
// • `guild.leave` / `guild.remove` need the roster and the board mirror to
// name who left and which guild it was.
//
// Awaited, unlike the broadcast and the push tickle below, and this is the one
// place this file waits on a notification path. It has to: the whole point is
// that the read happens first, and a fire-and-forget promise would race the
// DELETE it is trying to precede. `fromShardEvent` never throws and never opens
// a socket — it resolves ids and hands the engine an envelope, which does its
// own work off the caller's stack (`emit` is deliberately not awaited inside).
// Backfilled frames are excluded for the same reason the broadcast is: a
// reconnect replay must not re-notify anyone about events from hours ago.
if (!deps.fromBackfill) {
try {
await d.engagement(event)
} catch (err) {
d.log.warn('engagement fan-out failed', { kind: event.kind, message: err.message })
}
}
try { try {
await applyStateChange(event, d) await applyStateChange(event, d)
} catch (err) { } catch (err) {

View File

@@ -91,9 +91,22 @@ const FEATURES = {
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the // `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read // REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1. // model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
// actually know it, when the house collapses. It defaults to `anonymous` because
// that is what the public IDOC page is FOR: the countdown is the content, and a
// house at IDOC is already announced in game. It is listed rather than left
// unconfigurable so a shard that considers a precise collapse time an unfair
// advantage can raise it, and it is one NESTED key so raising it hides the whole
// schedule rather than three of its four parts.
houses: { houses: {
audience: 'anonymous', audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' }, fields: {
owner: 'staff',
ownerName: 'staff',
ownerSerial: 'staff',
price: 'staff',
schedule: 'anonymous',
},
}, },
// /public/shard/online listed linked staff to everyone but gated location to // /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung. // admin+moderator — which is exactly the `staff` rung.
@@ -124,9 +137,25 @@ const FEATURES = {
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses` // `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial // lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything. // that every other board resolves back to that name has not hidden anything.
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
// defaults. The shop name, the owner and the location are already visible to any
// player through the stock in-game Vendor Search gump, which is the whole 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.
// So it defaults to `admin`, the only default here that does not reproduce prior
// behaviour, because there is no prior behaviour to reproduce.
//
// Nested for the same reason `location` is: one rule covers all seven parts.
market: { market: {
audience: 'anonymous', audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' }, fields: {
ownerName: 'anonymous',
ownerSerial: 'anonymous',
location: 'anonymous',
fees: 'admin',
},
}, },
} }
@@ -177,6 +206,13 @@ const KIND_FEATURE = new Map(
// registry (house.update / house.remove — owner, price, co-owners) stays // registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before. // off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses', 'house.decay': 'houses',
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
// to admin-only, which is the right answer for a frame that carries an IP address
// and says whether a password was accepted — the same reasoning that keeps
// house.update and account.login.attempt off this map. Adding it would mean
// choosing a feature an admin could then widen, and there is no rung below admin
// this frame belongs on.
// v3 // v3
'world.ruleset': 'ruleset', 'world.ruleset': 'ruleset',
'points.board': 'leaderboards', 'points.board': 'leaderboards',