diff --git a/module.json b/module.json index 3fad288..ffc4999 100644 --- a/module.json +++ b/module.json @@ -1,8 +1,8 @@ { "id": "uo", "name": "Ultima Online", - "version": "0.4.0", - "coreApi": "^1.8.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/engagementSeeds.js b/server/config/engagementSeeds.js new file mode 100644 index 0000000..51d9d01 --- /dev/null +++ b/server/config/engagementSeeds.js @@ -0,0 +1,912 @@ +// ── 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 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', + '/player/uo/houses', + ), + + // ── 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', + '/shard/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', + '/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 {{autoPickAt}}: ' + + '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 {{autoPickAt}} — 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', '/player/uo/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', + '/player/uo/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', '/player/uo/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', + '/player/uo/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', + '/player/uo/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', + '/player/uo/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', '/shard/points'), + ], + ), + 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', + '/shard/points', + ), +] + +// **`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, + cancel_on: ['uo.house.collapsed'], + 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/shardTriggers.js b/server/config/shardTriggers.js index f4389ae..1ca47ac 100644 --- a/server/config/shardTriggers.js +++ b/server/config/shardTriggers.js @@ -56,6 +56,27 @@ // 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` → @@ -103,6 +124,12 @@ const OWNED_ASSET = [ description: 'When the house was last refreshed.' }, { name: 'houseUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, { @@ -123,6 +150,10 @@ const OWNED_ASSET = [ 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.' }, ], }, { @@ -160,6 +191,10 @@ const OWNED_ASSET = [ description: 'Where the shop stands, already formatted for reading.' }, { name: 'marketUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, ] @@ -195,6 +230,12 @@ const PASSIVE_INCOME = [ 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.' }, ], }, ] @@ -296,6 +337,8 @@ const PERSONAL_MILESTONE = [ 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.' }, ], }, { @@ -312,6 +355,8 @@ const PERSONAL_MILESTONE = [ 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.' }, ], }, ] @@ -345,6 +390,8 @@ const SOCIAL_CIVIC = [ description: 'Who left, when the roster mirror still knows their name.' }, { name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds', 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.' }, ], }, { @@ -363,6 +410,42 @@ const SOCIAL_CIVIC = [ 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: '/shard/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', @@ -381,6 +464,8 @@ const SOCIAL_CIVIC = [ description: 'Who held the seat before, when there was someone.' }, { name: 'governorsUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, { @@ -408,6 +493,10 @@ const SOCIAL_CIVIC = [ description: 'How many candidates stand.' }, { name: 'governorsUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, ] @@ -435,6 +524,8 @@ const COME_ONLINE = [ description: 'Where, already formatted for reading.' }, { name: 'champsUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, { @@ -457,6 +548,8 @@ const COME_ONLINE = [ description: 'Where, already formatted for reading.' }, { name: 'champsUrl', type: 'url', required: false, example: '/shard/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.' }, ], }, { @@ -529,6 +622,10 @@ const LEADERBOARD = [ 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.' }, ], }, ] diff --git a/server/index.js b/server/index.js index e79fef6..e9ca46a 100644 --- a/server/index.js +++ b/server/index.js @@ -46,6 +46,7 @@ module.exports = function register(ctx, api) { 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') @@ -115,6 +116,28 @@ module.exports = function register(ctx, api) { // 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. diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 5753295..f0bd101 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -129,6 +129,11 @@ function fakeApi() { // 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..339172c --- /dev/null +++ b/server/test/engagementSeeds.test.js @@ -0,0 +1,231 @@ +// ── 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 sixteen 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, 16) + assert.equal(seeds.TEMPLATES.length, 32) +}) + +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' }] }], +] + +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' }] }, 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, 25) +}) diff --git a/server/test/shardEngagement.test.js b/server/test/shardEngagement.test.js index ab6a667..3011499 100644 --- a/server/test/shardEngagement.test.js +++ b/server/test/shardEngagement.test.js @@ -29,7 +29,7 @@ const one = (event) => { // ── The catalogue itself ─────────────────────────────────────────────────── test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => { - assert.equal(TRIGGERS.length, 24) + assert.equal(TRIGGERS.length, 25) // 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. @@ -262,6 +262,36 @@ test('a governor change is a transition, and never on first sight', () => { 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 diff --git a/server/utils/shardEngagement.js b/server/utils/shardEngagement.js index 12b7759..0844dfb 100644 --- a/server/utils/shardEngagement.js +++ b/server/utils/shardEngagement.js @@ -144,6 +144,66 @@ const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct : // 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. +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?, @@ -173,6 +233,8 @@ const MAPPERS = { 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 @@ -186,6 +248,9 @@ const MAPPERS = { houseSerial: serial, 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), @@ -244,6 +309,7 @@ const MAPPERS = { data: defined({ vendorSerial: serial, 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". @@ -252,6 +318,13 @@ const MAPPERS = { 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', fees.dismissalAt], + ['Standing at', place(ev)], + ]), }), }) }, @@ -269,9 +342,18 @@ const MAPPERS = { 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], + ]), }), }) }, @@ -355,6 +437,7 @@ const MAPPERS = { data: defined({ characterName: actorName(ev.who) || 'your character', killerName: actorName(ev.killer), + slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`), }), }) }, @@ -368,6 +451,7 @@ const MAPPERS = { data: defined({ characterName: actorName(ev.victim) || 'your character', murdererName: actorName(ev.murderer), + slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`), }), }) }, @@ -412,14 +496,34 @@ const MAPPERS = { const prevGov = tracker.cityGovernor.get(city) tracker.cityGovernor.set(city, gov) if (prevGov !== undefined && gov && gov !== prevGov) { - out.push({ - triggerId: 'uo.governor.elected', - data: defined({ - city: String(city), - governorName: actorName(ev.governor) || 'a new governor', - previousGovernorName: undefined, - }), + 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), + 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 @@ -440,8 +544,13 @@ const MAPPERS = { data: defined({ city: String(city), phase, + phaseLabel: phaseLabel(phase), autoPickAt: 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.`), + ), }), }) } @@ -460,6 +569,7 @@ const MAPPERS = { 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) { @@ -531,7 +641,15 @@ const MAPPERS = { 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.`, }), }) }, @@ -727,6 +845,11 @@ async function resolveTarget(target, deps) { 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 } }