feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
11a declared the triggers; this is the content behind them. Ships through
core's new api.registerEngagementSeeds (MODULE_API 1.9.0): 32 templates and 25
rules, every rule enabled = 0.
THE VOICE (decision 8). The game-powered families read from inside Britannia,
with a per-family in-fiction sender rather than one voice across all sixteen —
Lord Blackthorn's court writes about the crown's business (the seat, the ballot)
and nothing else, because a shard where Blackthorn writes to you personally about
a champion spawn is a shard where the letter about your governorship means
nothing. The Office of Deeds has houses, the Merchants' Guild vendors, a herald
guilds, the town crier champion spawns, a guildmaster skills and quests, the
Chronicler deaths, the keeper of the rolls leaderboards.
WHAT STAYS PLAIN (decision 9). Nine of the 25 point at core's notify.event /
inapp.event and author nothing, and the line is drawn where fiction costs
something real: 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 a moderator reading uo.cheat.detected at 2am wants a name, a rule and a
timestamp rather than a scroll. Both account-security triggers, server up/down,
and the five staff/admin-ceiling ones.
THE GOVERNOR'S LETTER (decision 10) — uo.governor.appointed, the 25th trigger.
§8.6 records that uo.points.rank_changed cannot address a person because top[]
names a mobile serial, and the same reasoning was silently assumed to cover the
governor. It does not: city.update's `governor` is written by BridgeJson.Actor(),
which emits serial, name, acct AND webId. The winner is addressable today with no
protocol change. It fires from the same frame, the same transition and the same
never-on-first-sight guard as uo.governor.elected, which stays exactly as
declared — the town's bulletin and the governor's letter are two triggers because
one trigger means one rule means one template, and they are not the same text.
An operator can run either alone.
PRESENTATIONAL FRAGMENTS, because a template has no conditionals by design and an
unset optional interpolates to the empty string. Phase 5a's `forWhom` precedent:
the ternary stays in the mapper and its result arrives as a declared optional.
Two shapes — a LABEL always has a value and carries a sentence's spine
(houseLabel falls back to a seal number); a TRAILING FRAGMENT may be empty and
leads with its own space, so `{{slainBy}}.` closes as "has fallen." either way.
Additive, so no version bump.
A render sweep over all 32 bodies, twice — once with every declared example and
once with required variables only — is what found these. Three defects it caught:
an optional `{{region}}` in a subject line ("A notice concerning thy house at ");
multi-optional ledger lines rendering "On hand: gold. Charged each period:
gold." on a pre-v5 frame, now assembled in the mapper from the parts actually
present, the same argument place() already makes; and a leading trailing-fragment
opening a body with a stray space.
The labels stay `required: false` deliberately — a missing one must never REFUSE
an emit, since a dropped notification is worse than a cosmetic hole — so nothing
at runtime would notice a mapper that forgot one. engagementSeeds.test.js is what
notices.
524 module tests green; check:imports and check:bundle clean. check:swagger
reports STALE from CRLF alone and regenerates byte-identical — no route changed.
Refs docs ENGAGEMENT.md Phase 11b, decisions 8, 9, 10.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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.
|
||||
const V1 = 1
|
||||
|
||||
|
||||
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
|
||||
//
|
||||
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
|
||||
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
|
||||
// court. A letter is a sentence, and a template has no conditionals by design
|
||||
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
|
||||
// leaves a hole mid-clause: "The house , in , stands in peril."
|
||||
//
|
||||
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
|
||||
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
|
||||
// declared optional. Two shapes, and each `example` shows which it is —
|
||||
//
|
||||
// • a LABEL always has a value, so it can carry a sentence's spine;
|
||||
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
|
||||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||||
//
|
||||
// They are `required: false` and therefore additive: adding one is not a
|
||||
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
|
||||
// template written before them keeps working unchanged.
|
||||
|
||||
// ── Owned asset at risk — the flagship family ──────────────────────────────
|
||||
//
|
||||
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
|
||||
@@ -103,6 +124,12 @@ const OWNED_ASSET = [
|
||||
description: 'When the house was last refreshed.' },
|
||||
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
|
||||
description: 'Site-relative path to the IDOC page.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
|
||||
description: 'The decay stage as words rather than as the wire\'s enum.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -123,6 +150,10 @@ const OWNED_ASSET = [
|
||||
description: 'The named region it stood in.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||
description: 'Facet and coordinates, already formatted for reading.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -160,6 +191,10 @@ const OWNED_ASSET = [
|
||||
description: 'Where the shop stands, already formatted for reading.' },
|
||||
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
|
||||
description: 'Site-relative path to the market page.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
|
||||
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -195,6 +230,12 @@ const PASSIVE_INCOME = [
|
||||
description: 'What it sold for, in gold.' },
|
||||
{ name: 'commission', type: 'int', required: false, example: 0,
|
||||
description: 'Commission taken, on a commission vendor.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
|
||||
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
|
||||
description: 'The whole ledger line, or absent when the sale carried no commission.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -296,6 +337,8 @@ const PERSONAL_MILESTONE = [
|
||||
description: 'Who died. Also the cooldown subject.' },
|
||||
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
||||
description: 'What killed them, when the shard names it.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -312,6 +355,8 @@ const PERSONAL_MILESTONE = [
|
||||
description: 'Who was murdered. Also the cooldown subject.' },
|
||||
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who did it, when the shard names them.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -345,6 +390,8 @@ const SOCIAL_CIVIC = [
|
||||
description: 'Who left, when the roster mirror still knows their name.' },
|
||||
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
|
||||
description: 'Site-relative path to the guilds page.' },
|
||||
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -363,6 +410,42 @@ const SOCIAL_CIVIC = [
|
||||
description: 'Its abbreviation.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// **The town's bulletin and the governor's letter are two triggers, not one**
|
||||
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
|
||||
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
|
||||
// serial and links are keyed by account — and the same reasoning was silently
|
||||
// assumed to cover this one. It does not: `city.update`'s `governor` field is
|
||||
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
|
||||
// `webId`. The new governor is addressable today, with no protocol change.
|
||||
//
|
||||
// Widening `uo.governor.elected` to two audiences was the tempting answer and
|
||||
// was refused: one trigger means one rule means ONE template, and the town's
|
||||
// announcement and the governor's letter are not the same text. Two also lets
|
||||
// an operator run the announcement and leave the letter off, or the reverse.
|
||||
id: 'uo.governor.appointed',
|
||||
label: 'You were named governor',
|
||||
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
|
||||
kind: 'event',
|
||||
// The city, not the governor: a player who somehow takes two seats in an hour
|
||||
// should get two letters, and the seat is what the event is about.
|
||||
subjectKey: 'city',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||
description: 'The city whose seat you now hold. Also the cooldown subject.' },
|
||||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Your character\'s name, as the city knows it.' },
|
||||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||
description: 'Who held the seat before, when there was someone.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.governor.elected',
|
||||
label: 'A town elected a governor',
|
||||
@@ -381,6 +464,8 @@ const SOCIAL_CIVIC = [
|
||||
description: 'Who held the seat before, when there was someone.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -408,6 +493,10 @@ const SOCIAL_CIVIC = [
|
||||
description: 'How many candidates stand.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
||||
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
||||
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -435,6 +524,8 @@ const COME_ONLINE = [
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -457,6 +548,8 @@ const COME_ONLINE = [
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -529,6 +622,10 @@ const LEADERBOARD = [
|
||||
description: 'Who was first before.' },
|
||||
{ name: 'points', type: 'int', required: false, example: 29500,
|
||||
description: 'The new leader\'s points.' },
|
||||
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
|
||||
description: 'A label: the board\'s display name, or its system id when it has none.' },
|
||||
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
|
||||
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user