// ── 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}}', ), // ── The champion falls (Protocol 6) ───────────────────────────────────── // // The other half of the pair above, and the half the wire could not report // until protocol 6 gave the shard a kind for it. Written as the crier's own // follow-up: the same voice that announced the champion walking is the one // that reports it did not walk far. // // `{{damagerNote}}` is a single-token block, so an unattributed kill renders // the paragraph without it rather than as a sentence with a hole in it. email( 'uo.champ.boss-killed', 'Champion spawn — the champion falls (town crier)', 'uo.champ.boss_killed', 'Hear ye — {{bossName}} has fallen', [ heading('h', 'Hear ye, hear ye'), text('p1', '{{bossName}} has fallen{{atPlace}}.{{damagerNote}} The altar is quiet again, and it ' + 'will not stay quiet.'), button('cta', 'See the altars', '{{champsUrl}}'), ], ), inapp( 'uo.champ.boss-killed-inapp', 'Champion spawn — the champion falls (in-app)', 'uo.champ.boss_killed', '{{bossName}} has fallen', '{{bossName}} has fallen{{atPlace}}.{{damagerNote}}', 'See the altars', '{{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'] // The same two channels as CHANNELS_OWNER and a different reason for them: a // rule that goes to every subscriber but cannot be PUSHED, because push is // keyed on a subscription id and no trigger in this module is also a registered // stream. Same value, different fact — folding them into one constant would lose // the distinction the moment somebody added push to whichever one they read as // "the broadcast-ish list". See `uo.champ.boss_killed`. const CHANNELS_CONTENT = ['email', 'inapp'] /** 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.champ.boss_killed', name: 'Champion spawn — the champion falls', audience: 'subscribers', // **`CHANNELS_CONTENT`, not `CHANNELS_BROADCAST`** — this is the one rule in // the file that leaves push out, and it is not an oversight. // // Push delivery is keyed on the SUBSCRIPTION id, and a subscription row only // ever exists for an id the preferences screen offered a push toggle for — // which core's catalog grants to registered STREAMS and nothing else. This // module's stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids // (`uo.champ.started`, …) are disjoint sets, so no trigger here can be pushed // through the engagement path at all: the tickle resolves to zero endpoints // while the send log records it delivered. // // That is true of every sibling rule above and is a pre-existing defect, not // one this rule introduces. What this rule declines to do is add a // twenty-first instance of it. See EVENTS_PLAN.md Phase 11a. channels: CHANNELS_CONTENT, template_keys: bodies('champ.boss-killed'), // The same half-hour as its `boss_up` twin, and on the SAME subject — the // spawn — so an altar that pops and is cleared inside the window produces the // walk or the fall, not both. 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, }, ] // A group is seeded ONCE, under its own settings guard. So a rule appended to an // existing group reaches fresh installs and nothing else: every deployment that // has already stamped `triggers-v1` is done with it forever, and the new rule // would silently never arrive. That is Engagement Phase 11's seed-key finding, // and core applied the same remedy in Events Phase 10 — a NEW key per addition, // never an edit to an old one. // // So protocol 6's `uo.champ.boss_killed` rule ships as its own group rather than // as a twenty-seventh entry above. `RULES` remains the whole declared set, which // is what the "every declared trigger has exactly one rule" invariant reads. const BOSS_KILLED = RULES.filter((r) => r.trigger_id === 'uo.champ.boss_killed') const RULE_GROUPS = [ { key: 'triggers-v1', note: 'UO notifications stay off until an operator enables one', rules: RULES.filter((r) => !BOSS_KILLED.includes(r)), }, { key: 'champ-boss-killed-v1', note: 'The champion-falls notice, added with protocol 6; off like every other', rules: BOSS_KILLED, }, ] module.exports = { TEMPLATES, RULES, RULE_GROUPS }