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

Merged
whitlocktech merged 10 commits from edge into main 2026-09-01 13:59:04 +00:00
8 changed files with 1431 additions and 10 deletions
Showing only changes of commit 52d9c3ddb8 - Show all commits

View File

@@ -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",

View File

@@ -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 }

View File

@@ -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.' },
],
},
]

View File

@@ -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.

View File

@@ -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 },
}

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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({
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: actorName(ev.governor) || 'a new governor',
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 }
}