Files
Module-uo/server/config/engagementSeeds.js
Claude 849d4b10e8
Some checks failed
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 36s
PR Checks / client-build (pull_request) Successful in 8m17s
fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Needs website#<core> (the cooldown key and the seed-rule ceiling).

1. Every owner-audienced trigger reached NOBODY. `resolveTarget` read
   `link.user_id`; the model's `toSafe` returns `userId`. So the whole flagship
   family -- houses, vendors, logins, unlinks, deaths, the governor's letter --
   resolved to null and looked exactly like the ordinary unlinked-account case,
   which the code treats as normal and deliberately does not log.

   The test fake returned `user_id` and therefore agreed with the bug, while
   `shardStreams.test.js`'s fake next door -- same model, the path this file says
   it copies -- returned `userId`. The fake is now built by running the real
   `toSafe` over a stubbed db row, so the shape is not a hand-written opinion.

2. `uo.house.refreshed`, the 26th trigger (the org lead's decision 11). The
   warning's rule carries `delay_seconds: 900` so a player who repairs the house
   inside the quarter-hour is never told it is in peril -- and nothing could
   cancel it: `cancel_on` named only the collapse. The wire had carried the
   transition all along; the mapper returned early on it.

   It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case:
   a condemned house cannot be refreshed at all (`RefreshDecay()` refuses
   `DecayType.Condemned`), so the rescue is the owner logging in, and their
   newest house then reads `Ageless`. Ships a body and a seeded (disabled) rule
   of its own; the cancellation is read off the WARNING's rule and works whether
   or not the new one is enabled.

3. Every call-to-action in every in-universe body was a dead link, from two
   independent mistakes. The client router prefixes a module's routes with its
   ID (`/uo/houses`), not with module.json's `mounts` (`/shard/...`), so every
   declared `example` was a 404 -- and an example is what the template editor
   previews and test-sends with. And no `url` variable was ever populated by the
   mapper, so the buttons rendered with an empty href and dropped out of the text
   part entirely. Both now read `config/clientPaths.js`. Two tests close it.

4. A raw wire timestamp was signing off the Merchants' Guild's letter
   (`2026-09-02T04:06:43.8397548Z`, mid-sentence). Core has no interpolation
   filters by design, so the readable form is assembled in the mapper and arrives
   as its own variable; the machine value stays, because an operator writes
   `is at most` conditions against it.

Also fixes a latent flake: `hoursRemaining` floors a live clock, so a fixture at
a whole number asserted 19 or 20 depending on sub-millisecond timing.

527 module tests green (3 new). Proved end to end against real ServUO + the
release sidecar + a live SMTP catcher; see docs#<docs>.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:29 -05:00

970 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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