feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edge → main)
#26
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "uo",
|
"id": "uo",
|
||||||
"name": "Ultima Online",
|
"name": "Ultima Online",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"coreApi": "^1.8.0",
|
"coreApi": "^1.9.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
912
server/config/engagementSeeds.js
Normal file
912
server/config/engagementSeeds.js
Normal 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 }
|
||||||
@@ -56,6 +56,27 @@
|
|||||||
// is for, and a stored rule keeps working across it.
|
// is for, and a stored rule keeps working across it.
|
||||||
const V1 = 1
|
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 ──────────────────────────────
|
// ── Owned asset at risk — the flagship family ──────────────────────────────
|
||||||
//
|
//
|
||||||
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
|
// 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.' },
|
description: 'When the house was last refreshed.' },
|
||||||
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
|
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
|
||||||
description: 'Site-relative path to the IDOC page.' },
|
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.' },
|
description: 'The named region it stood in.' },
|
||||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||||
description: 'Facet and coordinates, already formatted for reading.' },
|
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.' },
|
description: 'Where the shop stands, already formatted for reading.' },
|
||||||
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
|
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
|
||||||
description: 'Site-relative path to the market page.' },
|
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.' },
|
description: 'What it sold for, in gold.' },
|
||||||
{ name: 'commission', type: 'int', required: false, example: 0,
|
{ name: 'commission', type: 'int', required: false, example: 0,
|
||||||
description: 'Commission taken, on a commission vendor.' },
|
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.' },
|
description: 'Who died. Also the cooldown subject.' },
|
||||||
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
||||||
description: 'What killed them, when the shard names it.' },
|
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.' },
|
description: 'Who was murdered. Also the cooldown subject.' },
|
||||||
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
||||||
description: 'Who did it, when the shard names them.' },
|
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.' },
|
description: 'Who left, when the roster mirror still knows their name.' },
|
||||||
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
|
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
|
||||||
description: 'Site-relative path to the guilds page.' },
|
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.' },
|
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',
|
id: 'uo.governor.elected',
|
||||||
label: 'A town elected a governor',
|
label: 'A town elected a governor',
|
||||||
@@ -381,6 +464,8 @@ const SOCIAL_CIVIC = [
|
|||||||
description: 'Who held the seat before, when there was someone.' },
|
description: 'Who held the seat before, when there was someone.' },
|
||||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||||
description: 'Site-relative path to the governors page.' },
|
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.' },
|
description: 'How many candidates stand.' },
|
||||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||||
description: 'Site-relative path to the governors page.' },
|
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.' },
|
description: 'Where, already formatted for reading.' },
|
||||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||||
description: 'Site-relative path to the champions page.' },
|
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.' },
|
description: 'Where, already formatted for reading.' },
|
||||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||||
description: 'Site-relative path to the champions page.' },
|
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.' },
|
description: 'Who was first before.' },
|
||||||
{ name: 'points', type: 'int', required: false, example: 29500,
|
{ name: 'points', type: 'int', required: false, example: 29500,
|
||||||
description: 'The new leader\'s points.' },
|
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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const shardStreams = require('./config/shardStreams')
|
const shardStreams = require('./config/shardStreams')
|
||||||
const shardTriggers = require('./config/shardTriggers')
|
const shardTriggers = require('./config/shardTriggers')
|
||||||
const shardAudiences = require('./config/shardAudiences')
|
const shardAudiences = require('./config/shardAudiences')
|
||||||
|
const engagementSeeds = require('./config/engagementSeeds')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
const guildCommand = require('./commands/guild.command')
|
const guildCommand = require('./commands/guild.command')
|
||||||
@@ -115,6 +116,28 @@ module.exports = function register(ctx, api) {
|
|||||||
// and registration must not (§2.2 rule 1).
|
// and registration must not (§2.2 rule 1).
|
||||||
api.registerAudiences(shardAudiences.AUDIENCES)
|
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
|
// 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;
|
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
|
||||||
// everything about what a guild IS stays here.
|
// everything about what a guild IS stays here.
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ function fakeApi() {
|
|||||||
// one, so a second call is a module changing its mind mid-register().
|
// one, so a second call is a module changing its mind mid-register().
|
||||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
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 },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
231
server/test/engagementSeeds.test.js
Normal file
231
server/test/engagementSeeds.test.js
Normal 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)
|
||||||
|
})
|
||||||
@@ -29,7 +29,7 @@ const one = (event) => {
|
|||||||
// ── The catalogue itself ───────────────────────────────────────────────────
|
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||||
|
|
||||||
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
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
|
// 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
|
// 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.
|
// 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' } })), [])
|
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', () => {
|
test('an election opening needs its deadline, or it does not fire', () => {
|
||||||
map(city({ electionPhase: 'none' }))
|
map(city({ electionPhase: 'none' }))
|
||||||
// **A "vote now" mail with nothing to act by is worse than none**, and
|
// **A "vote now" mail with nothing to act by is worse than none**, and
|
||||||
|
|||||||
@@ -144,6 +144,66 @@ const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct :
|
|||||||
// honest about what the frame actually carried.
|
// honest about what the frame actually carried.
|
||||||
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
|
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 ────────────────────────────────────────────────────────────
|
// ── The mappers ────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
|
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
|
||||||
@@ -173,6 +233,8 @@ const MAPPERS = {
|
|||||||
houseName: decayName(ev),
|
houseName: decayName(ev),
|
||||||
region: ev.region || undefined,
|
region: ev.region || undefined,
|
||||||
location: place(ev),
|
location: place(ev),
|
||||||
|
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||||
|
whereLine: detailLine([['Last recorded at', place(ev)]]),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -186,6 +248,9 @@ const MAPPERS = {
|
|||||||
houseSerial: serial,
|
houseSerial: serial,
|
||||||
houseName: decayName(ev),
|
houseName: decayName(ev),
|
||||||
stage: ev.to,
|
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,
|
previousStage: ev.from || undefined,
|
||||||
region: ev.region || undefined,
|
region: ev.region || undefined,
|
||||||
location: place(ev),
|
location: place(ev),
|
||||||
@@ -244,6 +309,7 @@ const MAPPERS = {
|
|||||||
data: defined({
|
data: defined({
|
||||||
vendorSerial: serial,
|
vendorSerial: serial,
|
||||||
shopName: ev.shopName || undefined,
|
shopName: ev.shopName || undefined,
|
||||||
|
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||||
dismissalAt: fees.dismissalAt,
|
dismissalAt: fees.dismissalAt,
|
||||||
// Never negative: a vendor already past its dismissal tick is being
|
// Never negative: a vendor already past its dismissal tick is being
|
||||||
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
|
// 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,
|
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
|
||||||
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
|
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
|
||||||
location: place(ev),
|
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({
|
data: defined({
|
||||||
vendorSerial: String(ev.vendorSerial ?? ''),
|
vendorSerial: String(ev.vendorSerial ?? ''),
|
||||||
itemName: ev.itemType || 'an item',
|
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,
|
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
|
||||||
price: Number.isFinite(ev.price) ? ev.price : 0,
|
price: Number.isFinite(ev.price) ? ev.price : 0,
|
||||||
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
|
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({
|
data: defined({
|
||||||
characterName: actorName(ev.who) || 'your character',
|
characterName: actorName(ev.who) || 'your character',
|
||||||
killerName: actorName(ev.killer),
|
killerName: actorName(ev.killer),
|
||||||
|
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -368,6 +451,7 @@ const MAPPERS = {
|
|||||||
data: defined({
|
data: defined({
|
||||||
characterName: actorName(ev.victim) || 'your character',
|
characterName: actorName(ev.victim) || 'your character',
|
||||||
murdererName: actorName(ev.murderer),
|
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)
|
const prevGov = tracker.cityGovernor.get(city)
|
||||||
tracker.cityGovernor.set(city, gov)
|
tracker.cityGovernor.set(city, gov)
|
||||||
if (prevGov !== undefined && gov && gov !== prevGov) {
|
if (prevGov !== undefined && gov && gov !== prevGov) {
|
||||||
out.push({
|
const governorName = actorName(ev.governor) || 'a new governor'
|
||||||
triggerId: 'uo.governor.elected',
|
// **The frame carries no previous holder by name.** The tracker holds the
|
||||||
data: defined({
|
// outgoing governor's SERIAL and nothing maps a serial to a name here, so
|
||||||
city: String(city),
|
// the succession fragment is empty today and the declaration is optional.
|
||||||
governorName: actorName(ev.governor) || 'a new governor',
|
// It is declared rather than omitted so the body does not have to be
|
||||||
previousGovernorName: undefined,
|
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
||||||
}),
|
const civic = defined({
|
||||||
|
city: String(city),
|
||||||
|
governorName,
|
||||||
|
previousGovernorName: undefined,
|
||||||
|
inSuccessionTo: undefined,
|
||||||
})
|
})
|
||||||
|
out.push({ triggerId: 'uo.governor.elected', data: civic })
|
||||||
|
|
||||||
|
// **And the letter to the person who won** (Phase 11b, decision 10). Same
|
||||||
|
// frame, same transition, same never-on-first-sight guard — a different
|
||||||
|
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
|
||||||
|
// every actor object it emits, so this needs no protocol change; an
|
||||||
|
// unlinked governor is simply nobody to write to, which `resolveTarget`
|
||||||
|
// already treats as an ordinary outcome rather than an error.
|
||||||
|
const acct = actorAcct(ev.governor)
|
||||||
|
if (acct) {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.governor.appointed',
|
||||||
|
ownerAccount: acct,
|
||||||
|
data: { ...civic },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
|
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
|
||||||
@@ -440,8 +544,13 @@ const MAPPERS = {
|
|||||||
data: defined({
|
data: defined({
|
||||||
city: String(city),
|
city: String(city),
|
||||||
phase,
|
phase,
|
||||||
|
phaseLabel: phaseLabel(phase),
|
||||||
autoPickAt: ev.autoPickAt,
|
autoPickAt: ev.autoPickAt,
|
||||||
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
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',
|
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||||
category: ev.category || undefined,
|
category: ev.category || undefined,
|
||||||
location: place(ev),
|
location: place(ev),
|
||||||
|
atPlace: trailing(place(ev), (p) => ` at ${p}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (wasActive !== undefined && isActive && wasActive !== true) {
|
if (wasActive !== undefined && isActive && wasActive !== true) {
|
||||||
@@ -531,7 +641,15 @@ const MAPPERS = {
|
|||||||
system: String(system),
|
system: String(system),
|
||||||
systemName: ev.nameString || undefined,
|
systemName: ev.nameString || undefined,
|
||||||
leaderName: leader.name || 'a new leader',
|
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,
|
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 members = await state.listGuildMembers(target.guildId)
|
||||||
const gone = members.find((m) => String(m.serial) === target.memberSerial)
|
const gone = members.find((m) => String(m.serial) === target.memberSerial)
|
||||||
if (gone && gone.name) data.memberName = gone.name
|
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 }
|
return { data, recipientUserIds: userIds }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user