7 Commits

Author SHA1 Message Date
1590b52bc8 Merge pull request 'feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edge → main)' (#26) from edge into main
All checks were successful
SonarQube / analysis (push) Successful in 1m26s
Release / release (push) Successful in -51s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 13:59:02 +00:00
3a81766526 Merge pull request 'ci(core-ref): pin core at MODULE_API 1.9.0, unbreaking frozen-manifest' (#25) from ci/bump-core-ref-1.9.0 into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / client-build (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 39s
Reviewed-on: #25
2026-09-01 12:52:04 +00:00
3139cb4364 ci(core-ref): pin core at MODULE_API 1.9.0, unbreaking frozen-manifest
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in -35s
The pin was `963d734` -- website `main` at the Teams cutover, MODULE_API **1.6.0**.
That core has no `ceilings.js`, no `registerEventTriggers` and no
`registerAudiences`, so this module has failed to load into it since Phase 11a
added the first of those calls, and `frozen-manifest` has been red on every
engagement PR since. The last green run was #39 (`6a276a7`, Phase 10's
protocol-5 ingest), which added no `register*` call and so still loaded.

The red X was never about the PR in front of it. This is the bump the org lead
scheduled for the moment website#178 landed; it should have ridden in
Module-uo#24 and did not.

New pin: `52eac24` -- website `edge` carrying MODULE_API 1.9.0
(`registerEngagementSeeds`) and the Phase 11b core fixes (website#179).

`routes.manifest.json` is unchanged and is NOT regenerated here: this phase's
work added triggers, bodies and rules, and not one route. The job's own check
confirms it -- 73 routes, all documented.

Reproduced locally the way the job does it: core at the new sha, manifest without
the module, module installed with `npm ci --omit=dev`, manifest with it, then
`frozenManifest.js --check`. The module registers cleanly (26 triggers, 3
audiences) and the check passes. `check:imports` and `check:bundle` clean.

Reverts to a `main` sha at the Phase 13 cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:35:57 -05:00
17a96ed4c4 Merge pull request 'fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger' (#24) from fix/engagement-live-walk-uo into edge
Reviewed-on: #24
2026-09-01 12:32:55 +00:00
849d4b10e8 fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Some checks failed
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 36s
PR Checks / client-build (pull_request) Successful in 8m17s
Needs website#<core> (the cooldown key and the seed-rule ceiling).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:29 -05:00
52d9c3ddb8 Merge pull request 'feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)' (#23) from feature/engagement-uo-templates into edge
Reviewed-on: #23
2026-09-01 06:35:44 +00:00
50a89b48e2 feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
Some checks failed
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 22s
PR Checks / frozen-manifest (pull_request) Failing after -34s
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>
2026-09-01 01:02:15 -05:00
10 changed files with 1814 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
{ {
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.", "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", "ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9",
"refName": "main @ the Teams cutover (website#161)" "refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)"
} }

View File

@@ -1,8 +1,8 @@
{ {
"id": "uo", "id": "uo",
"name": "Ultima Online", "name": "Ultima Online",
"version": "0.4.0", "version": "0.5.0",
"coreApi": "^1.8.0", "coreApi": "^1.9.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql", "schema": "server/db/schema.sql",

View File

@@ -0,0 +1,46 @@
// ── The module's own client paths, in one place ────────────────────────────
//
// Every link a notification puts in front of a player is a path into this
// module's SPA routes, and Phase 11b's live walk found that not one of them was
// right: the declared examples all read `/shard/…` (module.json's `mounts`), the
// bodies hard-coded a mixture of `/shard/…` and `/player/uo/…`, and the mapper
// populated none of the URL variables at all — so every in-universe letter shipped
// with an empty href and every template preview showed a dead one.
//
// **The prefix is the module ID, not the mount.** `registry.registerRoutes`
// prefixes a module's client routes with `<id>/` and nothing else
// (`client/src/modules/registry.js`), which is why `module.json`'s `mounts` is not
// the answer — that field says what the module CLAIMS, and the router says where
// it landed. `client/src/entry.jsx`'s own `registerNav` is the check: the hrefs it
// gives the sidebar are these, and if the two ever disagree the sidebar is right.
//
// Kept server-side and shared by BOTH the trigger declarations (their `example`s,
// which the template editor previews and test-sends with) and the seeded bodies,
// so a route that moves is one edit rather than thirty.
const ID = 'uo'
const PATHS = {
shard: `/${ID}/shard`,
champs: `/${ID}/champs`,
guilds: `/${ID}/guilds`,
governors: `/${ID}/governors`,
houses: `/${ID}/houses`,
atlas: `/${ID}/atlas`,
leaderboards: `/${ID}/leaderboards`,
market: `/${ID}/market`,
// Self-service and staff areas sit under core's own wrappers, so they carry
// core's prefix as well as the module's.
characters: `/player/${ID}/characters`,
ops: `/admin/${ID}/ops`,
}
/** One guild's roster, when the frame names a guild; the list otherwise. */
const guildPath = (guildId) =>
(guildId === undefined || guildId === null ? PATHS.guilds : `${PATHS.guilds}/${guildId}`)
/** One vendor's page, when the frame names one; the market otherwise. */
const vendorPath = (serial) =>
(serial ? `${PATHS.market}/vendors/${serial}` : PATHS.market)
module.exports = { PATHS, guildPath, vendorPath }

View File

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

View File

@@ -56,6 +56,27 @@
// is for, and a stored rule keeps working across it. // is for, and a stored rule keeps working across it.
const V1 = 1 const V1 = 1
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
//
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
// court. A letter is a sentence, and a template has no conditionals by design
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
// leaves a hole mid-clause: "The house , in , stands in peril."
//
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
// declared optional. Two shapes, and each `example` shows which it is —
//
// • a LABEL always has a value, so it can carry a sentence's spine;
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// They are `required: false` and therefore additive: adding one is not a
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
// template written before them keeps working unchanged.
// ── Owned asset at risk — the flagship family ────────────────────────────── // ── Owned asset at risk — the flagship family ──────────────────────────────
// //
// All three resolve through the frame's `ownerAcct` → `shard_account_links` → // All three resolve through the frame's `ownerAcct` → `shard_account_links` →
@@ -101,8 +122,14 @@ const OWNED_ASSET = [
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' }, description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z', { name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
description: 'When the house was last refreshed.' }, description: 'When the house was last refreshed.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses', { name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the IDOC page.' }, description: 'Site-relative path to the IDOC page.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
description: 'The decay stage as words rather than as the wire\'s enum.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
], ],
}, },
{ {
@@ -123,6 +150,51 @@ const OWNED_ASSET = [
description: 'The named region it stood in.' }, description: 'The named region it stood in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600', { name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' }, description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
],
},
{
// **The good outcome, and it exists because a delay without a cancel is just
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
// `delay_seconds: 900` so an owner who repairs the house inside the window is
// never told it is in peril — and until Phase 11b's live walk there was
// nothing that could cancel it: the mapper returned early on every transition
// that was not a late stage, so a refresh reached the engine as silence. The
// wire already carried the transition; only this declaration was missing.
//
// It is a real notification as well as a cancel signal (decision 11), so it
// carries the labels a body needs rather than the serial alone.
id: 'uo.house.refreshed',
label: 'Your house was refreshed',
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
kind: 'event',
// The SAME subject as the warning it cancels, and that is load-bearing rather
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
// whose subject were anything else would cancel nothing.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject, and what the cancellation matches on.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
description: 'The decay stage it was in before it was refreshed.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the housing 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: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
], ],
}, },
{ {
@@ -158,8 +230,12 @@ const OWNED_ASSET = [
description: 'What each tick deducts.' }, description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)', { name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
description: 'Where the shop stands, already formatted for reading.' }, description: 'Where the shop stands, already formatted for reading.' },
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market', { name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
description: 'Site-relative path to the market page.' }, description: 'Site-relative path to the market page.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
], ],
}, },
] ]
@@ -195,6 +271,12 @@ const PASSIVE_INCOME = [
description: 'What it sold for, in gold.' }, description: 'What it sold for, in gold.' },
{ name: 'commission', type: 'int', required: false, example: 0, { name: 'commission', type: 'int', required: false, example: 0,
description: 'Commission taken, on a commission vendor.' }, description: 'Commission taken, on a commission vendor.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
description: 'The whole ledger line, or absent when the sale carried no commission.' },
], ],
}, },
] ]
@@ -296,6 +378,8 @@ const PERSONAL_MILESTONE = [
description: 'Who died. Also the cooldown subject.' }, description: 'Who died. Also the cooldown subject.' },
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord', { name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
description: 'What killed them, when the shard names it.' }, description: 'What killed them, when the shard names it.' },
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
], ],
}, },
{ {
@@ -312,6 +396,8 @@ const PERSONAL_MILESTONE = [
description: 'Who was murdered. Also the cooldown subject.' }, description: 'Who was murdered. Also the cooldown subject.' },
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow', { name: 'murdererName', type: 'string', required: false, example: 'Darrow',
description: 'Who did it, when the shard names them.' }, description: 'Who did it, when the shard names them.' },
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
], ],
}, },
] ]
@@ -343,8 +429,10 @@ const SOCIAL_CIVIC = [
// is optional because a member the sweep never saw has no row there. // is optional because a member the sweep never saw has no row there.
{ name: 'memberName', type: 'string', required: false, example: 'Bran', { name: 'memberName', type: 'string', required: false, example: 'Bran',
description: 'Who left, when the roster mirror still knows their name.' }, description: 'Who left, when the roster mirror still knows their name.' },
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds', { name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
description: 'Site-relative path to the guilds page.' }, description: 'Site-relative path to the guilds page.' },
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
], ],
}, },
{ {
@@ -363,6 +451,42 @@ const SOCIAL_CIVIC = [
description: 'Its abbreviation.' }, description: 'Its abbreviation.' },
], ],
}, },
{
// **The town's bulletin and the governor's letter are two triggers, not one**
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
// serial and links are keyed by account — and the same reasoning was silently
// assumed to cover this one. It does not: `city.update`'s `governor` field is
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
// `webId`. The new governor is addressable today, with no protocol change.
//
// Widening `uo.governor.elected` to two audiences was the tempting answer and
// was refused: one trigger means one rule means ONE template, and the town's
// announcement and the governor's letter are not the same text. Two also lets
// an operator run the announcement and leave the letter off, or the reverse.
id: 'uo.governor.appointed',
label: 'You were named governor',
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
kind: 'event',
// The city, not the governor: a player who somehow takes two seats in an hour
// should get two letters, and the seat is what the event is about.
subjectKey: 'city',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city whose seat you now hold. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'Your character\'s name, as the city knows it.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
],
},
{ {
id: 'uo.governor.elected', id: 'uo.governor.elected',
label: 'A town elected a governor', label: 'A town elected a governor',
@@ -379,8 +503,10 @@ const SOCIAL_CIVIC = [
description: 'The new governor.' }, description: 'The new governor.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille', { name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' }, description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors', { name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' }, description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
], ],
}, },
{ {
@@ -404,10 +530,21 @@ const SOCIAL_CIVIC = [
description: 'Which phase opened: nominate or vote.' }, description: 'Which phase opened: nominate or vote.' },
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z', { name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
description: 'When the game decides for itself — the real deadline.' }, description: 'When the game decides for itself — the real deadline.' },
// The same instant a person can read. A `datetime` renders as the string the
// payload holds and core has no interpolation filters by design, so a body
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
// The machine value STAYS — an operator writes `is at most` conditions
// against it — and the body uses this one.
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
{ name: 'candidates', type: 'int', required: false, example: 3, { name: 'candidates', type: 'int', required: false, example: 3,
description: 'How many candidates stand.' }, description: 'How many candidates stand.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors', { name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' }, description: 'Site-relative path to the governors page.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
description: 'The phase as a clause rather than as the wire\'s enum.' },
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
], ],
}, },
] ]
@@ -433,8 +570,10 @@ const COME_ONLINE = [
description: 'champion, mini or sea.' }, description: 'champion, mini or sea.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570', { name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' }, description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs', { name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' }, description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
], ],
}, },
{ {
@@ -455,8 +594,10 @@ const COME_ONLINE = [
description: 'The boss, when the shard names it.' }, description: 'The boss, when the shard names it.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570', { name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' }, description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs', { name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' }, description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
], ],
}, },
{ {
@@ -476,7 +617,7 @@ const COME_ONLINE = [
variables: [ variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon', { name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' }, description: 'What the shard calls itself.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/shard', { name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' }, description: 'Site-relative path to the shard status page.' },
], ],
}, },
@@ -493,7 +634,7 @@ const COME_ONLINE = [
description: 'What the shard calls itself.' }, description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true, { name: 'clean', type: 'boolean', required: false, example: true,
description: 'Whether it was a clean shutdown rather than a crash.' }, description: 'Whether it was a clean shutdown rather than a crash.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/shard', { name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' }, description: 'Site-relative path to the shard status page.' },
], ],
}, },
@@ -529,6 +670,10 @@ const LEADERBOARD = [
description: 'Who was first before.' }, description: 'Who was first before.' },
{ name: 'points', type: 'int', required: false, example: 29500, { name: 'points', type: 'int', required: false, example: 29500,
description: 'The new leader\'s points.' }, description: 'The new leader\'s points.' },
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
description: 'A label: the board\'s display name, or its system id when it has none.' },
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
], ],
}, },
] ]
@@ -558,7 +703,7 @@ const STAFF_FACING = [
description: 'What they wrote.' }, description: 'What they wrote.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699', { name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
description: 'Where they are, already formatted for reading.' }, description: 'Where they are, already formatted for reading.' },
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/shard', { name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
description: 'Site-relative path to the help-page queue.' }, description: 'Site-relative path to the help-page queue.' },
], ],
}, },
@@ -637,7 +782,7 @@ const OPERATOR_FACING = [
description: 'The threshold it crossed.' }, description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up', { name: 'direction', type: 'string', required: true, example: 'up',
description: 'up or down.' }, description: 'up or down.' },
{ name: 'economyUrl', type: 'url', required: false, example: '/shard', { name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' }, description: 'Site-relative path to the shard status page.' },
], ],
}, },

View File

@@ -46,6 +46,7 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams') const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers') const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences') const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const townCrierLeg = require('./utils/shardAnnounce') const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model') const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command') const guildCommand = require('./commands/guild.command')
@@ -115,6 +116,28 @@ module.exports = function register(ctx, api) {
// and registration must not (§2.2 rule 1). // and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES) api.registerAudiences(shardAudiences.AUDIENCES)
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
// Phase 11b): sixteen in-universe message bodies on two channels each, and
// twenty-five rules — every one of them `enabled = 0`, which the registry
// enforces rather than trusts.
//
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
// that is on still passes the ceiling, the per-user preference, the suppression
// list and the verification gate before anything is sent — all of them core's.
//
// The nine security and operational triggers point at core's generic bodies
// (decision 9). A cheat report should read like a cheat report.
//
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
// fresh installs ONLY. A future trigger wants its own group key.
api.registerEngagementSeeds({
templates: engagementSeeds.TEMPLATES,
ruleGroups: engagementSeeds.RULE_GROUPS,
})
// Teams: a UO guild is a Team, and this module is the authoritative source of // Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions; // them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here. // everything about what a guild IS stays here.

View File

@@ -129,6 +129,11 @@ function fakeApi() {
// one, so a second call is a module changing its mind mid-register(). // one, so a second call is a module changing its mind mid-register().
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers }, registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences }, registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
// and merging two calls would make "which group is this rule in" — the
// question the one-shot seed guard answers — unanswerable.
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
} }

View File

@@ -0,0 +1,242 @@
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
//
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
// file proves the content shipped alongside them is coherent — which is a
// different failure mode and a quieter one: a rule pointing at a template key
// that does not exist, or a body built around a variable nothing supplies, is
// invisible until somebody enables the rule and a person does not get a mail.
//
// The three properties worth asserting, none of which a hand run would catch:
//
// 1. **Every rule names a trigger this module declares, and a template that
// exists** — its own or core's nine generic keys.
// 2. **Every LABEL a body builds a sentence around is supplied on every path
// that emits its trigger.** This is the one that earns its keep. The
// fragments are declared `required: false` so a missing one can never
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
// and that leaves nothing at runtime to notice a mapper that forgot one.
// This test is what notices.
// 3. **The plain nine are plain** (decision 9). A security notice drifting
// into the in-universe register is exactly the change nobody would think to
// review, and it is the one with a real cost attached.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const seeds = require('../config/engagementSeeds')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
// property 1). Spelled out rather than imported: this module cannot require core,
// and a key disappearing from core is exactly the breakage worth failing on.
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
const PLAIN = new Set([
'uo.account.login_failed', 'uo.account.unlinked',
'uo.server.up', 'uo.server.down',
'uo.page.new', 'uo.cheat.detected',
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
])
// ── The shape of the set ───────────────────────────────────────────────────
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
const ruled = seeds.RULES.map((r) => r.trigger_id)
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
})
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
for (const r of seeds.RULES) {
// `enabled` is not set here at all — the registry forces 0 — so the
// assertion is that nobody added it. Q3's invariant, at the source.
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
}
})
test('every template key a rule names exists — its own or core\'s', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
for (const r of seeds.RULES) {
for (const [channel, key] of Object.entries(r.template_keys)) {
assert.ok(
own.has(key) || CORE_KEYS.has(key),
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
)
}
}
})
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
let bespoke = 0
for (const r of seeds.RULES) {
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
if (PLAIN.has(r.trigger_id)) {
// **Decision 9, as a check.** A security notice written as a letter is
// indistinguishable in register from the phishing mail it warns about.
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
continue
}
bespoke += 1
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
// Both channels in the same voice: one rule fires on both at once, and a
// player who reads the inbox item and then the mail must not meet two
// different narrators.
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
// The DIGEST stays core's. A day of events rolled into a list is not a
// letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 34)
})
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
// is a genuinely confusing pair and the reason this is asserted rather than
// remembered. Caught at registration too, as a boot failure.
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
for (const t of seeds.TEMPLATES) {
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
}
})
test('an email body has a subject and an in-app body has none', () => {
for (const t of seeds.TEMPLATES) {
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
}
})
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
// One prebuilt image mails as any shard. An in-universe body is UO-specific
// and must still be shard-agnostic.
const json = JSON.stringify(seeds.TEMPLATES)
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
}
})
// ── The property the render sweep needed ───────────────────────────────────
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
// trailing ones that may legitimately be empty. A frame that exercises each.
const LABELLED = [
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
['uo.house.collapsed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
['uo.vendor.sale', ['shopLabel', 'itemLine'],
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
// has a hole in it without one. It is `required: false` like the others and
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
// all unless the frame carried `autoPickAt`.
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
['uo.house.refreshed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
]
test('every label a body builds a sentence around is supplied by the mapper', () => {
for (const [triggerId, labels, frame] of LABELLED) {
// A first frame is never a transition, so the upsert kinds need a prior one.
engagement.mapShardEvent(
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
tracker,
)
const targets = engagement.mapShardEvent(frame, tracker)
const target = targets.find((t) => t.triggerId === triggerId)
assert.ok(target, `${triggerId} fired`)
for (const label of labels) {
assert.ok(
target.data[label] !== undefined && target.data[label] !== '',
`${triggerId} supplies ${label} — a body builds a sentence around it`,
)
}
}
})
test('a label is supplied even when every optional field is absent', () => {
// The case the render sweep modelled: a v4 overlay, a house with no name and
// no region. `houseLabel` falls back to the seal number, which is worse prose
// and better than "Be it known that , recorded to thy name".
const target = engagement.mapShardEvent(
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
tracker,
)[0]
assert.match(target.data.houseLabel, /0x40012345/)
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
// The detail line names only what the frame carried — "Recorded at: ." is the
// shape this avoids. The stage is always there, so the line is too; a house
// with no coordinates simply does not get the "Recorded at" half.
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
})
test('a detail line names only the parts the frame actually carried', () => {
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
const at = new Date(Date.now() + 3600_000).toISOString()
const target = engagement.mapShardEvent(
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
tracker,
)[0]
assert.equal(target.triggerId, 'uo.vendor.expiring')
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
})
// ── Trailing fragments ─────────────────────────────────────────────────────
test('a trailing fragment leads with its own space, or is absent entirely', () => {
// `{{slainBy}}.` must close as "has fallen." with no fragment and
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
// its leading space produces "has fallenat the hands of" and nothing would
// notice.
const withKiller = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
tracker,
)[0]
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
const without = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
tracker,
)[0]
assert.equal(without.data.slainBy, undefined)
})
test('every declared fragment carries an example that shows its own shape', () => {
// The `example` is what the template editor previews and test-sends with, so a
// trailing fragment whose example omits the leading space teaches an author the
// wrong thing about where to put one.
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
}
}
})
// ── The group key ──────────────────────────────────────────────────────────
test('one rule group, and appending to it later would reach fresh installs only', () => {
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
// rule has to edit a test whose name says what appending costs.
assert.equal(seeds.RULE_GROUPS.length, 1)
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
})

View File

@@ -14,6 +14,7 @@ const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement') const engagement = require('../utils/shardEngagement')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers') const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS } = require('../config/clientPaths')
let tracker let tracker
beforeEach(() => { tracker = engagement.createTracker() }) beforeEach(() => { tracker = engagement.createTracker() })
@@ -29,7 +30,7 @@ const one = (event) => {
// ── The catalogue itself ─────────────────────────────────────────────────── // ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => { test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
assert.equal(TRIGGERS.length, 24) assert.equal(TRIGGERS.length, 26)
// The four rows that do NOT ship, each with its reason recorded in §8.6. This // The four rows that do NOT ship, each with its reason recorded in §8.6. This
// assertion is the guard on the carve-outs: adding one back is a decision, and // assertion is the guard on the carve-outs: adding one back is a decision, and
// a decision should have to edit a test that says so. // a decision should have to edit a test that says so.
@@ -73,6 +74,72 @@ test('a url variable is site-RELATIVE — an absolute one ends up in an href', (
} }
}) })
test('a url example names a route this module actually mounts', () => {
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
// `mounts` — and the client router prefixes a module's routes with its **ID**
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
// over: the example is what the template editor previews and test-sends with,
// and `clientPaths.js` is now the single place both it and the bodies read.
const known = new Set(Object.values(PATHS))
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
const parent = v.example.replace(/\/[^/]+$/, '')
assert.ok(
known.has(v.example) || known.has(parent),
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
)
}
}
})
test('every url variable a body can interpolate is actually SUPPLIED', () => {
// The defect this exists for is invisible in the source and invisible in a
// fixture: a declared-but-never-populated optional interpolates to the empty
// string, so the letter renders perfectly and its call-to-action button has no
// href. Nine of the sixteen in-universe bodies shipped that way.
//
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
// added is covered the day it is declared.
const frames = {
'uo.house.idoc_warning': DECAY,
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
'uo.vendor.expiring': listing(FEES(20)),
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
// Two frames each: an upsert kind is never a transition on FIRST sight, so
// the tracker has to see a baseline before the change means anything.
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
'uo.server.down': { kind: 'server.shutdown' },
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
}
for (const t of TRIGGERS) {
const urls = t.variables.filter((v) => v.type === 'url')
if (!urls.length) continue
const frame = frames[t.id]
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
const fresh = engagement.createTracker()
let target = null
for (const f of Array.isArray(frame) ? frame : [frame]) {
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
if (hit) target = hit
}
assert.ok(target, `${t.id} did not fire for its frame`)
for (const v of urls) {
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-relative`)
}
}
})
// The declaration that the whole ceiling lattice exists for. // The declaration that the whole ceiling lattice exists for.
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => { test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected') const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
@@ -107,12 +174,29 @@ test('a late decay stage warns the owner; an early one says nothing', () => {
assert.equal(t.ownerAccount, 'seed_002') assert.equal(t.ownerAccount, 'seed_002')
assert.equal(t.data.stage, 'Greatly') assert.equal(t.data.stage, 'Greatly')
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)') assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
// A house being refreshed is the normal case. Mailing it would make the // An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
// warning worthless. // not news, and mailing it would make the warning worthless.
assert.deepEqual(ids({ ...DECAY, to: 'LikeNew' }), [])
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), []) assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
}) })
test('a refresh is its own trigger, and it is what cancels the warning', () => {
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
// the decay sweep has always emitted this transition.
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
assert.equal(t.triggerId, 'uo.house.refreshed')
assert.equal(t.ownerAccount, 'seed_002')
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
// (rule, subject_key), so a different one would cancel nothing.
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
assert.equal(t.data.previousStage, 'Greatly')
// A TRAILING fragment: its own leading space, and empty rather than reading
// "It stood in decay." when the previous stage has no word of its own.
assert.equal(t.data.fromLine, ' It stood greatly worn.')
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
})
test('the v5 schedule rides along when present and is simply absent when not', () => { test('the v5 schedule rides along when present and is simply absent when not', () => {
const withSchedule = one({ const withSchedule = one({
...DECAY, ...DECAY,
@@ -179,10 +263,13 @@ test('a vendor entering the warning window fires ONCE, not on every sweep frame'
// `vendor.listing` is re-emitted on any price change, so without the crossing // `vendor.listing` is re-emitted on any price change, so without the crossing
// check a vendor inside the window mails its owner every time somebody // check a vendor inside the window mails its owner every time somebody
// reprices a longsword. // reprices a longsword.
const first = one(listing(FEES(20))) // 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
// number the answer is 20 or 19 depending on whether a millisecond has passed
// since the fixture was built, and this assertion was flaking on exactly that.
const first = one(listing(FEES(20.5)))
assert.equal(first.triggerId, 'uo.vendor.expiring') assert.equal(first.triggerId, 'uo.vendor.expiring')
assert.equal(first.ownerAccount, 'darrow_acct') assert.equal(first.ownerAccount, 'darrow_acct')
assert.equal(first.data.hoursRemaining, 19) // floor of 20h minus the tick spent here assert.equal(first.data.hoursRemaining, 20)
assert.deepEqual(ids(listing(FEES(19))), []) assert.deepEqual(ids(listing(FEES(19))), [])
assert.deepEqual(ids(listing(FEES(18))), []) assert.deepEqual(ids(listing(FEES(18))), [])
}) })
@@ -262,6 +349,36 @@ test('a governor change is a transition, and never on first sight', () => {
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), []) assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
}) })
test('an ELECTED governor with a linked account also gets a letter', () => {
// Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a
// person because `top[]` names a serial — and the same reasoning was silently
// assumed to cover the governor. It does not: `BridgeJson.Actor()` writes
// `acct` on every actor object, so the winner is addressable with no protocol
// change. This test is the record of that, and of the decision that the
// announcement and the letter are TWO triggers.
map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } }))
const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } }))
assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed'])
const letter = out[1]
assert.equal(letter.ownerAccount, 'darrow')
assert.equal(letter.data.city, 'Britain')
assert.equal(letter.data.governorName, 'Darrow')
// The bulletin carries no owner — it is the town's, not the governor's.
assert.equal(out[0].ownerAccount, undefined)
})
test('an UNLINKED governor still gets the town its announcement', () => {
// Nobody to write to is an ordinary outcome, not an error — most game accounts
// on most shards have never been linked — and it must not cost the city its
// proclamation.
map(city({ governor: { serial: '0x1', name: 'Mireille' } }))
assert.deepEqual(
ids(city({ governor: { serial: '0x2', name: 'Darrow' } })),
['uo.governor.elected'],
)
})
test('an election opening needs its deadline, or it does not fire', () => { test('an election opening needs its deadline, or it does not fire', () => {
map(city({ electionPhase: 'none' })) map(city({ electionPhase: 'none' }))
// **A "vote now" mail with nothing to act by is worse than none**, and // **A "vote now" mail with nothing to act by is worse than none**, and
@@ -418,6 +535,26 @@ test('an unmapped kind and a malformed frame both produce nothing', () => {
// ── Resolution: the half that reaches the database ───────────────────────── // ── Resolution: the half that reaches the database ─────────────────────────
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
// one, taken FROM that model rather than written out here: the model's `toSafe`
// camel-cases the row, and a hand-written fake using the column names is a fake
// that will agree with a resolver reading the column names. Stubbing the db
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
function linkRow(account, userId) {
const realGet = shardLinksDb.getByAccount
shardLinksDb.getByAccount = async () => ({
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
})
try {
return shardLinksModel.getByAccount(account)
} finally {
shardLinksDb.getByAccount = realGet
}
}
function deps(over = {}) { function deps(over = {}) {
const emitted = [] const emitted = []
return { return {
@@ -425,7 +562,11 @@ function deps(over = {}) {
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }), emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
tracker, tracker,
shardLinks: { shardLinks: {
getByAccount: async (acct) => (acct === 'seed_002' ? { account: acct, user_id: 7 } : null), // Shaped by the REAL model's `toSafe`, not by the column names. A fake that
// returns `user_id` agrees with a resolver that reads `user_id`, and the
// pair passes while every owner-audienced trigger reaches nobody on a live
// shard — which is exactly what happened. `linkRow` below is the guard.
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 7) : null),
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []), userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
...over.shardLinks, ...over.shardLinks,
}, },

View File

@@ -50,6 +50,7 @@
const shardLinks = require('../model/shardLinks/shardLinks.model') const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model') const shardState = require('../model/shardState/shardState.model')
const { TRIGGER_IDS } = require('../config/shardTriggers') const { TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS, guildPath } = require('../config/clientPaths')
const core = require('../core') const core = require('../core')
const log = core.logger('shard-engagement') const log = core.logger('shard-engagement')
@@ -144,6 +145,95 @@ const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct :
// honest about what the frame actually carried. // honest about what the frame actually carried.
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
// ── Presentational fragments (ENGAGEMENT.md Phase 11b, decision 8) ─────────
//
// **A template has no conditionals, by design** (`interpolate.js`: no filters,
// no loops, no ternaries), and an unset optional interpolates to the EMPTY
// STRING. That is exactly right for `notify.event`, whose variables are
// structural — but the in-universe bodies are sentences, and a sentence with a
// hole in the middle of it reads as a bug: "The house , in , stands in peril."
//
// So the ternary stays at the call site and its RESULT arrives as a declared
// optional variable, which is Phase 5a's `forWhom` precedent unchanged. Two
// shapes, and the difference matters when you write one:
//
// • a LABEL always has a value, so it can carry a sentence's spine
// (`houseLabel` is a name, or a seal number when there is no name);
// • a TRAILING FRAGMENT may be empty and leads with its own space, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// Every one of them is declared `required: false` on the trigger with an
// `example` showing precisely what it produces, leading space included — which
// is what the template editor previews and test-sends with.
/** A trailing fragment, or undefined when there is nothing to say. */
const trailing = (value, build) => (value ? build(value) : undefined)
// "The Silver Anvil, in Britain" · "the house under seal 0x40001234". A house
// often has no name and sometimes no region, and the warning has to name
// SOMETHING the owner can act on — a seal number is worse prose and better than
// a blank.
const houseLabel = (name, region, serial) => {
const named = name ? `“${name}”` : `the house under seal ${serial}`
return region ? `${named}, in ${region}` : named
}
// The decay stages as words rather than as the wire's enum. `Greatly` in the
// middle of a sentence is the shard's vocabulary leaking into a letter.
const STAGE_WORDS = {
FAIRLY: 'fairly worn',
GREATLY: 'greatly worn',
IDOC: 'in imminent danger of collapse',
}
const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] || 'in decay'
// The election phases likewise: `nominate` and `vote` are wire values.
// A whole DETAIL LINE, assembled from the parts that are actually present.
//
// The same argument `place()` above makes, one level up: a template that has to
// assemble four optional numbers into a sentence is a template every author gets
// slightly differently, and one whose optionals are absent renders
// "On hand: gold. Charged each period: gold." — which is what the render sweep
// found on a pre-v5 vendor frame. Passing the assembled line means the template
// interpolates ONE variable and the empty case is empty rather than punctuated.
// A wire instant as a person reads it: "2 September 2026, 04:06 UTC".
//
// Core deliberately has no interpolation filters (`interpolate.js` — no ternaries,
// no formatters), so a `datetime` variable renders as whatever string the payload
// holds — and the wire's is an ISO-8601 stamp with seven decimal places, which is
// what a letter from the Merchants' Guild was signing off with. Same argument as
// `place()` and `detailLine()` one line down: the presentation is assembled here,
// at the call site, and arrives as its own value.
//
// **The machine value is never replaced.** `dismissalAt` and `autoPickAt` are
// declared `datetime` and an operator can write `is at most` conditions against
// them (`conditions.js`), so the readable form is an ADDITIONAL variable and the
// ISO one stays exactly as it was.
const readableTime = (iso) => {
if (!iso) return undefined
const at = new Date(iso)
if (Number.isNaN(at.getTime())) return undefined
const day = at.getUTCDate()
const month = MONTHS[at.getUTCMonth()]
const hh = String(at.getUTCHours()).padStart(2, '0')
const mm = String(at.getUTCMinutes()).padStart(2, '0')
return `${day} ${month} ${at.getUTCFullYear()}, ${hh}:${mm} UTC`
}
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const detailLine = (parts) => {
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
}
const PHASE_WORDS = { nominate: 'Nominations are open', vote: 'The ballot is open' }
const phaseLabel = (phase) => PHASE_WORDS[String(phase || '')] || 'The election has moved'
// ── The mappers ──────────────────────────────────────────────────────────── // ── The mappers ────────────────────────────────────────────────────────────
// //
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?, // Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
@@ -173,6 +263,47 @@ const MAPPERS = {
houseName: decayName(ev), houseName: decayName(ev),
region: ev.region || undefined, region: ev.region || undefined,
location: place(ev), location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
whereLine: detailLine([['Last recorded at', place(ev)]]),
}),
})
return
}
// **The good outcome** (Phase 11b decision 11). A house refreshed back to
// LikeNew is what `uo.house.idoc_warning`'s 900-second delay exists to give
// the owner time to do, and until this branch the refresh reached the engine
// as silence — so the delay was a late mail rather than a cancellable one.
// Nothing on the wire changed: the decay sweep has always emitted this
// transition, and the early return below was swallowing it.
// **`AGELESS` as well as `LIKENEW`, and the first is the commoner case.**
// ServUO reports `LikeNew` for a house that is still on a decay clock and
// has just been refreshed (`DecayType.ManualRefresh`), and `Ageless` for one
// that is no longer on a clock at all — which is what the owner's newest
// house becomes the moment they log back in, because `DecayType` flips to
// `AutoRefresh` and the getter stops advancing the stage. A returning player
// is the ordinary way a decaying house is rescued, so reading only `LikeNew`
// would miss most rescues. Both mean "out of danger", which is what this
// trigger says.
if (to === 'LIKENEW' || to === 'AGELESS') {
out.push({
triggerId: 'uo.house.refreshed',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
previousStage: ev.from || undefined,
region: ev.region || undefined,
location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
// A TRAILING FRAGMENT, so it leads with its own space and the sentence
// closes cleanly without it. Built only for a stage that has a word —
// "It stood in decay." is the fallback label leaking into prose, and an
// empty fragment reads better than that.
fromLine: trailing(
STAGE_WORDS[String(ev.from || '').toUpperCase()] ? ev.from : null,
(stage) => ` It stood ${stageLabel(stage)}.`,
),
}), }),
}) })
return return
@@ -184,8 +315,12 @@ const MAPPERS = {
ownerAccount: ev.ownerAcct, ownerAccount: ev.ownerAcct,
data: defined({ data: defined({
houseSerial: serial, houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev), houseName: decayName(ev),
stage: ev.to, stage: ev.to,
houseLabel: houseLabel(decayName(ev), ev.region, serial),
stageLabel: stageLabel(ev.to),
whereLine: detailLine([['Recorded at', place(ev)], ['Stage entered', ev.to]]),
previousStage: ev.from || undefined, previousStage: ev.from || undefined,
region: ev.region || undefined, region: ev.region || undefined,
location: place(ev), location: place(ev),
@@ -243,7 +378,9 @@ const MAPPERS = {
ownerAccount: ev.ownerAcct, ownerAccount: ev.ownerAcct,
data: defined({ data: defined({
vendorSerial: serial, vendorSerial: serial,
marketUrl: PATHS.market,
shopName: ev.shopName || undefined, shopName: ev.shopName || undefined,
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
dismissalAt: fees.dismissalAt, dismissalAt: fees.dismissalAt,
// Never negative: a vendor already past its dismissal tick is being // Never negative: a vendor already past its dismissal tick is being
// destroyed, and "-3 hours remaining" in a mail is worse than "0". // destroyed, and "-3 hours remaining" in a mail is worse than "0".
@@ -252,6 +389,13 @@ const MAPPERS = {
funds: Number.isFinite(fees.funds) ? fees.funds : undefined, funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined, chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
location: place(ev), location: place(ev),
ledgerLine: detailLine([
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
['Dismissal', readableTime(fees.dismissalAt)],
['Standing at', place(ev)],
]),
}), }),
}) })
}, },
@@ -269,9 +413,18 @@ const MAPPERS = {
data: defined({ data: defined({
vendorSerial: String(ev.vendorSerial ?? ''), vendorSerial: String(ev.vendorSerial ?? ''),
itemName: ev.itemType || 'an item', itemName: ev.itemType || 'an item',
// "3 × Iron Ingot" or just "Iron Ingot" — `amount` is optional and a
// sentence reading "sold Iron Ingot" is the hole this closes.
itemLine: Number.isFinite(ev.amount) && ev.amount > 1
? `${ev.amount} × ${ev.itemType || 'an item'}`
: (ev.itemType || 'an item'),
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
amount: Number.isFinite(ev.amount) ? ev.amount : undefined, amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
price: Number.isFinite(ev.price) ? ev.price : 0, price: Number.isFinite(ev.price) ? ev.price : 0,
commission: Number.isFinite(ev.commission) ? ev.commission : undefined, commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
ledgerLine: detailLine([
['Commission withheld', Number.isFinite(ev.commission) ? `${ev.commission} gold` : undefined],
]),
}), }),
}) })
}, },
@@ -355,6 +508,7 @@ const MAPPERS = {
data: defined({ data: defined({
characterName: actorName(ev.who) || 'your character', characterName: actorName(ev.who) || 'your character',
killerName: actorName(ev.killer), killerName: actorName(ev.killer),
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
}), }),
}) })
}, },
@@ -368,6 +522,7 @@ const MAPPERS = {
data: defined({ data: defined({
characterName: actorName(ev.victim) || 'your character', characterName: actorName(ev.victim) || 'your character',
murdererName: actorName(ev.murderer), murdererName: actorName(ev.murderer),
slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`),
}), }),
}) })
}, },
@@ -386,7 +541,7 @@ const MAPPERS = {
// already left, so there is nothing for the shard to attribute. The name is // already left, so there is nothing for the shard to attribute. The name is
// looked up from the roster mirror in `dispatch`. // looked up from the roster mirror in `dispatch`.
memberSerial: ev.who == null ? null : String(ev.who), memberSerial: ev.who == null ? null : String(ev.who),
data: defined({ guildName: ev.name || `guild ${ev.id}` }), data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
}) })
}, },
@@ -412,14 +567,35 @@ const MAPPERS = {
const prevGov = tracker.cityGovernor.get(city) const prevGov = tracker.cityGovernor.get(city)
tracker.cityGovernor.set(city, gov) tracker.cityGovernor.set(city, gov)
if (prevGov !== undefined && gov && gov !== prevGov) { if (prevGov !== undefined && gov && gov !== prevGov) {
out.push({ const governorName = actorName(ev.governor) || 'a new governor'
triggerId: 'uo.governor.elected', // **The frame carries no previous holder by name.** The tracker holds the
data: defined({ // outgoing governor's SERIAL and nothing maps a serial to a name here, so
// the succession fragment is empty today and the declaration is optional.
// It is declared rather than omitted so the body does not have to be
// rewritten the day `city.update` gains a `previousGovernor` actor.
const civic = defined({
city: String(city), city: String(city),
governorName: actorName(ev.governor) || 'a new governor', governorsUrl: PATHS.governors,
governorName,
previousGovernorName: undefined, previousGovernorName: undefined,
}), inSuccessionTo: undefined,
}) })
out.push({ triggerId: 'uo.governor.elected', data: civic })
// **And the letter to the person who won** (Phase 11b, decision 10). Same
// frame, same transition, same never-on-first-sight guard — a different
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
// every actor object it emits, so this needs no protocol change; an
// unlinked governor is simply nobody to write to, which `resolveTarget`
// already treats as an ordinary outcome rather than an error.
const acct = actorAcct(ev.governor)
if (acct) {
out.push({
triggerId: 'uo.governor.appointed',
ownerAccount: acct,
data: { ...civic },
})
}
} }
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase // An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
@@ -439,9 +615,16 @@ const MAPPERS = {
triggerId: 'uo.election.opened', triggerId: 'uo.election.opened',
data: defined({ data: defined({
city: String(city), city: String(city),
governorsUrl: PATHS.governors,
phase, phase,
phaseLabel: phaseLabel(phase),
autoPickAt: ev.autoPickAt, autoPickAt: ev.autoPickAt,
autoPickWhen: readableTime(ev.autoPickAt),
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined, candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
candidateNote: trailing(
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
(n) => (n === 1 ? ' One candidate stands.' : ` ${n} candidates stand.`),
),
}), }),
}) })
} }
@@ -457,9 +640,11 @@ const MAPPERS = {
const base = defined({ const base = defined({
spawnSerial: serial, spawnSerial: serial,
champsUrl: PATHS.champs,
spawnName: ev.name || ev.type || 'a champion spawn', spawnName: ev.name || ev.type || 'a champion spawn',
category: ev.category || undefined, category: ev.category || undefined,
location: place(ev), location: place(ev),
atPlace: trailing(place(ev), (p) => ` at ${p}`),
}) })
if (wasActive !== undefined && isActive && wasActive !== true) { if (wasActive !== undefined && isActive && wasActive !== true) {
@@ -494,20 +679,20 @@ const MAPPERS = {
if (wasUp === true) return if (wasUp === true) return
out.push({ out.push({
triggerId: 'uo.server.up', triggerId: 'uo.server.up',
data: defined({ shardName: ev.shard || undefined }), data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
}) })
}, },
'server.shutdown': (ev, tracker, out) => { 'server.shutdown': (ev, tracker, out) => {
if (tracker.serverUp === false) return if (tracker.serverUp === false) return
tracker.serverUp = false tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { clean: true } }) out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
}, },
'server.crashed': (ev, tracker, out) => { 'server.crashed': (ev, tracker, out) => {
if (tracker.serverUp === false) return if (tracker.serverUp === false) return
tracker.serverUp = false tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { clean: false } }) out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
}, },
// ── Leaderboard ──────────────────────────────────────────────────────── // ── Leaderboard ────────────────────────────────────────────────────────
@@ -531,7 +716,15 @@ const MAPPERS = {
system: String(system), system: String(system),
systemName: ev.nameString || undefined, systemName: ev.nameString || undefined,
leaderName: leader.name || 'a new leader', leaderName: leader.name || 'a new leader',
// The board frame carries no previous holder — the tracker holds only a
// SERIAL, and a serial is not a name — so this is the one family whose
// trailing fragment is always empty today. Declared anyway, because the
// alternative is a body that has to be rewritten when the frame gains it.
boardLabel: ev.nameString || String(system),
points: Number.isFinite(leader.points) ? leader.points : undefined, points: Number.isFinite(leader.points) ? leader.points : undefined,
standingLine: Number.isFinite(leader.points)
? `${leader.name || 'a new leader'} now stands first upon it, with ${leader.points} to their name.`
: `${leader.name || 'a new leader'} now stands first upon it.`,
}), }),
}) })
}, },
@@ -541,6 +734,7 @@ const MAPPERS = {
out.push({ out.push({
triggerId: 'uo.page.new', triggerId: 'uo.page.new',
data: defined({ data: defined({
pagesUrl: PATHS.ops,
pageType: String(ev.type || 'Other'), pageType: String(ev.type || 'Other'),
senderName: actorName(ev.sender), senderName: actorName(ev.sender),
message: ev.message || undefined, message: ev.message || undefined,
@@ -620,6 +814,7 @@ const MAPPERS = {
out.push({ out.push({
triggerId: 'uo.economy.milestone', triggerId: 'uo.economy.milestone',
data: defined({ data: defined({
economyUrl: PATHS.shard,
metric, metric,
value: Math.round(value), value: Math.round(value),
threshold: crossed, threshold: crossed,
@@ -681,10 +876,16 @@ async function resolveTarget(target, deps) {
// `owner` — one account, one user. An unlinked account is nobody to notify, // `owner` — one account, one user. An unlinked account is nobody to notify,
// which is a normal outcome and not an error: most game accounts on most shards // which is a normal outcome and not an error: most game accounts on most shards
// have never been linked. // have never been linked.
//
// **`userId`, not `user_id`.** The model's `toSafe` camel-cases the row on the
// way out, so reading the column name silently makes EVERY owner-audienced
// trigger resolve to nobody — indistinguishable, from here and from the logs,
// from the ordinary unlinked-account case above. `shardPush.js` is the
// precedent this file follows and it reads `owner.userId`.
if (target.ownerAccount) { if (target.ownerAccount) {
const link = await links.getByAccount(target.ownerAccount) const link = await links.getByAccount(target.ownerAccount)
if (!link || link.user_id == null) return null if (!link || link.userId == null) return null
return { data, ownerUserId: Number(link.user_id) } return { data, ownerUserId: Number(link.userId) }
} }
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial. // `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
@@ -696,10 +897,10 @@ async function resolveTarget(target, deps) {
const house = houses.find((h) => String(h.serial) === target.houseSerial) const house = houses.find((h) => String(h.serial) === target.houseSerial)
if (!house || !house.ownerAcct) return null if (!house || !house.ownerAcct) return null
const link = await links.getByAccount(house.ownerAcct) const link = await links.getByAccount(house.ownerAcct)
if (!link || link.user_id == null) return null if (!link || link.userId == null) return null
if (house.name) data.houseName = house.name if (house.name) data.houseName = house.name
if (house.region) data.region = house.region if (house.region) data.region = house.region
return { data, ownerUserId: Number(link.user_id) } return { data, ownerUserId: Number(link.userId) }
} }
// `members` — the guild's roster, resolved to website users through // `members` — the guild's roster, resolved to website users through
@@ -727,6 +928,11 @@ async function resolveTarget(target, deps) {
const members = await state.listGuildMembers(target.guildId) const members = await state.listGuildMembers(target.guildId)
const gone = members.find((m) => String(m.serial) === target.memberSerial) const gone = members.find((m) => String(m.serial) === target.memberSerial)
if (gone && gone.name) data.memberName = gone.name if (gone && gone.name) data.memberName = gone.name
// The in-universe body's spine (Phase 11b decision 8). Built HERE and not
// in the mapper because the name comes from the roster mirror, which the
// mapper cannot read — and a herald's notice that names nobody is worse
// than one that says "a member".
if (data.guildName !== undefined) data.memberLabel = data.memberName || 'A member'
} }
return { data, recipientUserIds: userIds } return { data, recipientUserIds: userIds }
} }