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