diff --git a/README.md b/README.md index 0756b68..0f47d6c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no server/router/ routers + controllers, one directory per tier server/model/ one directory per table family; nothing crosses the boundary 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/purge.sql destructive; only ever run by an explicit purge 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 ``` +**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 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 diff --git a/ci/core-ref.json b/ci/core-ref.json index 3119e37..eed7298 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -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.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", - "refName": "main @ the Teams cutover (website#161)" + "ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9", + "refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)" } diff --git a/module.json b/module.json index 26610c2..ffc4999 100644 --- a/module.json +++ b/module.json @@ -1,8 +1,8 @@ { "id": "uo", "name": "Ultima Online", - "version": "0.3.0", - "coreApi": "^1.3.0", + "version": "0.5.0", + "coreApi": "^1.9.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", diff --git a/server/config/clientPaths.js b/server/config/clientPaths.js new file mode 100644 index 0000000..15d3d94 --- /dev/null +++ b/server/config/clientPaths.js @@ -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 `/` 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 } diff --git a/server/config/engagementSeeds.js b/server/config/engagementSeeds.js new file mode 100644 index 0000000..7548123 --- /dev/null +++ b/server/config/engagementSeeds.js @@ -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 } diff --git a/server/config/shardAudiences.js b/server/config/shardAudiences.js new file mode 100644 index 0000000..97e6279 --- /dev/null +++ b/server/config/shardAudiences.js @@ -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 } diff --git a/server/config/shardTriggers.js b/server/config/shardTriggers.js new file mode 100644 index 0000000..5b73676 --- /dev/null +++ b/server/config/shardTriggers.js @@ -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, +} diff --git a/server/core.js b/server/core.js index 076a05d..1959383 100644 --- a/server/core.js +++ b/server/core.js @@ -93,6 +93,19 @@ module.exports = { }, auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...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: { encrypt: (...args) => need().secretBox.encrypt(...args), decrypt: (...args) => need().secretBox.decrypt(...args), diff --git a/server/db/schema.sql b/server/db/schema.sql index b370e20..e86ca58 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config ( base_url VARCHAR(255) NULL, ws_url VARCHAR(255) 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, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', 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_name VARCHAR(64) NULL; 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'); diff --git a/server/index.js b/server/index.js index 1421178..e9ca46a 100644 --- a/server/index.js +++ b/server/index.js @@ -44,6 +44,9 @@ module.exports = function register(ctx, api) { const usersShardExtension = require('./router/admin/usersShard.router') 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 teamProvider = require('./model/teamProvider/teamProvider.model') const guildCommand = require('./commands/guild.command') @@ -88,6 +91,53 @@ module.exports = function register(ctx, api) { api.registerNotificationStreams(shardStreams.STREAMS) 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 // them for this deployment (MODULE_API 1.6.0). Core asks the three questions; // everything about what a guild IS stays here. @@ -114,5 +164,7 @@ module.exports = function register(ctx, api) { version: require('../module.json').version, routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard', streams: shardStreams.STREAMS.length, + triggers: shardTriggers.TRIGGERS.length, + audiences: shardAudiences.AUDIENCES.length, }) } diff --git a/server/model/shardLinks/shardLinks.db.js b/server/model/shardLinks/shardLinks.db.js index f9a00e9..9a304a5 100644 --- a/server/model/shardLinks/shardLinks.db.js +++ b/server/model/shardLinks/shardLinks.db.js @@ -39,4 +39,56 @@ const remove = (account, userId) => const removeByAccount = (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, +} + diff --git a/server/model/shardLinks/shardLinks.model.js b/server/model/shardLinks/shardLinks.model.js index 3d0f907..6da10f8 100644 --- a/server/model/shardLinks/shardLinks.model.js +++ b/server/model/shardLinks/shardLinks.model.js @@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId) // Drop the local mirror for an account (source-of-truth severed elsewhere). 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, +} diff --git a/server/model/shardMarket/shardMarket.db.js b/server/model/shardMarket/shardMarket.db.js index 7e33578..e631852 100644 --- a/server/model/shardMarket/shardMarket.db.js +++ b/server/model/shardMarket/shardMarket.db.js @@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) { await conn.query( `INSERT INTO shard_vendors - (serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house, - item_count, item_total, truncated, t) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + (serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house, + item_count, item_total, truncated, t, + 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), - 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), item_count = VALUES(item_count), item_total = VALUES(item_total), 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: -- MariaDB does not fire that when every column is written back -- unchanged, and a shop that is re-published identically is still @@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) { vendor.shopName ?? null, vendor.ownerSerial ?? null, vendor.ownerName ?? null, + vendor.ownerAcct ?? null, vendor.map ?? null, Number.isFinite(vendor.x) ? vendor.x : 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, vendor.truncated ? 1 : 0, 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, ], ) diff --git a/server/model/shardMarket/shardMarket.model.js b/server/model/shardMarket/shardMarket.model.js index 423fad4..9e2d818 100644 --- a/server/model/shardMarket/shardMarket.model.js +++ b/server/model/shardMarket/shardMarket.model.js @@ -31,6 +31,7 @@ const MAX_OWNER = 64 const MAX_MAP = 40 const MAX_REGION = 80 const MAX_SERIAL = 20 +const MAX_ACCT = 120 const clip = (value, max) => { if (value == null) return null @@ -43,6 +44,42 @@ const int = (value, fallback = 0) => { 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 ───────────────────────────────────────────────────────────────── /** @@ -64,6 +101,10 @@ function flattenFrame(ev) { shopName: clip(ev.shopName, MAX_SHOP), ownerSerial: clip(ev.ownerSerial, MAX_SERIAL), 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), x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : 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)), truncated: ev.truncated === true, t: Number.isFinite(ev.t) ? ev.t : null, + ...fees(ev.fees), } } diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js index 4dbc569..8250a63 100644 --- a/server/model/shardState/shardState.db.js +++ b/server/model/shardState/shardState.db.js @@ -98,7 +98,12 @@ async function latestEconomy() { // ── Houses / IDOC ──────────────────────────────────────────────────────── 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) @@ -215,6 +220,26 @@ const listGuildMembers = (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 // 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. @@ -391,6 +416,8 @@ module.exports = { removeGuildMember, clearAllGuildMembers, listGuildMembers, + listGuildMemberAccounts, + listGovernorAccounts, findGuildLedByActor, listGuildsLedByAccounts, upsertGovernor, diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js index 7c25254..2f9d975 100644 --- a/server/model/shardState/shardState.model.js +++ b/server/model/shardState/shardState.model.js @@ -124,10 +124,39 @@ async function upsertHouse(data) { built_on: data.builtOn ? new Date(data.builtOn) : null, last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, 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) } +// 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) { return { serial: r.serial, @@ -149,6 +178,16 @@ function shapeHouse(r) { inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), builtOn: r.built_on, 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), 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) { const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload return payload || { @@ -703,6 +755,8 @@ module.exports = { upsertGuildRoster, removeGuildMember, listGuildMembers, + listGuildMemberAccounts, + listGovernorAccounts, replaceGuilds, findGuildForActor, listGuildsLedForAccounts, diff --git a/server/model/uoLinkConfig/uoLinkConfig.model.js b/server/model/uoLinkConfig/uoLinkConfig.model.js index 80ae4f3..fe45ddd 100644 --- a/server/model/uoLinkConfig/uoLinkConfig.model.js +++ b/server/model/uoLinkConfig/uoLinkConfig.model.js @@ -11,13 +11,16 @@ const { secretBox } = require('../../core') // 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. // -// This says 4 because this build handles protocol 4's frames: `guild.roster` and -// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after -// 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 -// new deployment read nothing from its shard until an admin edited the number by -// hand in Admin → Shard. See the matching cutover in db/schema.sql. -const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4 +// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`, +// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind. +// +// It said 4 before that, and 3 for a while after protocol 4 shipped — 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 new deployment read nothing +// 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) { if (!row) { @@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt 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 } diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 6397e16..f0bd101 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) { settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) }, auth: { getUserFromRequest: spy(null) }, 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') }, middleware: { requireAuth: (req, res, next) => next(), @@ -97,6 +102,8 @@ function fakeApi() { legs: [], teamProvider: null, slashCommands: [], + triggers: null, + audiences: null, hooks: {}, } const called = new Set() @@ -117,6 +124,16 @@ function fakeApi() { // takes it: a second call is a module changing its mind halfway through // register(), which core rejects. 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 }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, } diff --git a/server/test/engagementSeeds.test.js b/server/test/engagementSeeds.test.js new file mode 100644 index 0000000..a0f66ed --- /dev/null +++ b/server/test/engagementSeeds.test.js @@ -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) +}) diff --git a/server/test/schemaFragment.test.js b/server/test/schemaFragment.test.js index 57352b4..53bd9b4 100644 --- a/server/test/schemaFragment.test.js +++ b/server/test/schemaFragment.test.js @@ -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 // 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', () => { + 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)) assert.ok(create, 'uo_link_config is gone') assert.match( 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', ) @@ -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), ) 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', () => { diff --git a/server/test/shardEngagement.test.js b/server/test/shardEngagement.test.js new file mode 100644 index 0000000..06aa935 --- /dev/null +++ b/server/test/shardEngagement.test.js @@ -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, []) +}) diff --git a/server/test/shardMarket.model.test.js b/server/test/shardMarket.model.test.js index 70c18c9..702337b 100644 --- a/server/test/shardMarket.model.test.js +++ b/server/test/shardMarket.model.test.js @@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () => const found = await clilocs.resolveMany([1023721]) 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) + } +}) diff --git a/server/test/shardState.model.test.js b/server/test/shardState.model.test.js index 1b439e7..8428289 100644 --- a/server/test/shardState.model.test.js +++ b/server/test/shardState.model.test.js @@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps assert.equal(typeof out[0].startedAt, 'number') 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') +}) diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js index 3e53c80..771e874 100644 --- a/server/test/shardVisibility.test.js +++ b/server/test/shardVisibility.test.js @@ -476,3 +476,109 @@ test('a link lookup failure downgrades rather than escalating', async () => { visibility.forgetUser(6) 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) +}) diff --git a/server/utils/shardEngagement.js b/server/utils/shardEngagement.js new file mode 100644 index 0000000..13914ee --- /dev/null +++ b/server/utils/shardEngagement.js @@ -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, +} diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index b21f6f1..03762de 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -20,6 +20,7 @@ const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const { settings: settingsModel } = require('../core') const broadcaster = require('./shardBroadcast') const shardPush = require('./shardPush') +const shardEngagement = require('./shardEngagement') const defaultLog = require('../core').logger('shard-ingest') // 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 } function reset() { 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? @@ -169,8 +175,14 @@ async function applyStateChange(event, deps) { name: event.name, ownerSerial: event.ownerSerial, 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, lastRefreshed: event.lastRefreshed, + schedule: event.schedule, }) return case 'champ.update': @@ -279,6 +291,7 @@ function resolveDeps(deps) { settings: deps.settings || settingsModel, broadcast: deps.broadcast || broadcaster.broadcast, pushDispatch: deps.pushDispatch || shardPush.fromShardEvent, + engagement: deps.engagement || shardEngagement.fromShardEvent, log: deps.log || defaultLog, } } @@ -294,6 +307,34 @@ async function ingest(event, deps = {}) { let stored = 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 { await applyStateChange(event, d) } catch (err) { diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js index a098569..8f50510 100644 --- a/server/utils/shardVisibility.js +++ b/server/utils/shardVisibility.js @@ -91,9 +91,22 @@ const FEATURES = { // `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 // 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: { 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 // admin+moderator — which is exactly the `staff` rung. @@ -124,9 +137,25 @@ const FEATURES = { // `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 // 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: { 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 // off the map deliberately, so it remains admin-only exactly as before. '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 'world.ruleset': 'ruleset', 'points.board': 'leaderboards',