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

Merged
whitlocktech merged 10 commits from edge into main 2026-09-01 13:59:04 +00:00
6 changed files with 403 additions and 47 deletions
Showing only changes of commit 17a96ed4c4 - Show all commits

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

@@ -126,6 +126,8 @@ const inapp = (key, name, triggerId, title, body, action, url) => ({
// ── The sixteen in-universe bodies ───────────────────────────────────────── // ── The sixteen in-universe bodies ─────────────────────────────────────────
const { PATHS } = require('./clientPaths')
const TEMPLATES = [ const TEMPLATES = [
// ── The Office of Deeds — houses ──────────────────────────────────────── // ── The Office of Deeds — houses ────────────────────────────────────────
// //
@@ -185,7 +187,39 @@ const TEMPLATES = [
'Thy house has fallen', '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.', '{{houseLabel}} has fallen, and the deed is struck from the ledger. The ground is open to any who would build there.',
'Review thy holdings', 'Review thy holdings',
'/player/uo/houses', 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 ────────────────────────────────────── // ── The Merchants' Guild — vendors ──────────────────────────────────────
@@ -242,7 +276,7 @@ const TEMPLATES = [
'A sale at thy shop', 'A sale at thy shop',
'{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.', '{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.',
'Open the market', 'Open the market',
'/shard/market', PATHS.market,
), ),
// ── A guild herald ────────────────────────────────────────────────────── // ── A guild herald ──────────────────────────────────────────────────────
@@ -292,7 +326,7 @@ const TEMPLATES = [
'{{guildName}} is dissolved', '{{guildName}} is dissolved',
'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.', 'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.',
'Open the shard', 'Open the shard',
'/shard', PATHS.shard,
), ),
// ── Lord Blackthorn's court — the crown's business ────────────────────── // ── Lord Blackthorn's court — the crown's business ──────────────────────
@@ -366,7 +400,7 @@ const TEMPLATES = [
text('p1', text('p1',
'{{phaseLabel}} in {{city}}.{{candidateNote}}'), '{{phaseLabel}} in {{city}}.{{candidateNote}}'),
text('p2', text('p2',
'Those who hold the loyalty of the city may speak. Attend before {{autoPickAt}}: ' '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 ' + '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.'), + 'complaint from any who could have spoken and did not.'),
button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'), button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
@@ -377,7 +411,7 @@ const TEMPLATES = [
'Election — the ballot opens (in-app)', 'Election — the ballot opens (in-app)',
'uo.election.opened', 'uo.election.opened',
'{{phaseLabel}} in {{city}}', '{{phaseLabel}} in {{city}}',
'Attend before {{autoPickAt}} — after that hour the matter is decided without thee.{{candidateNote}}', 'Attend before {{autoPickWhen}} — after that hour the matter is decided without thee.{{candidateNote}}',
'Attend the city', 'Attend the city',
'{{governorsUrl}}', '{{governorsUrl}}',
), ),
@@ -446,7 +480,7 @@ const TEMPLATES = [
+ '{{cap}} is the whole of it; there is no further mark to reach.'), + '{{cap}} is the whole of it; there is no further mark to reach.'),
text('p2', text('p2',
'What thou dost with it is thine own affair. The guild has taught thee what it knows.'), 'What thou dost with it is thine own affair. The guild has taught thee what it knows.'),
button('cta', 'Read thy character', '/player/uo/characters'), button('cta', 'Read thy character', PATHS.characters),
], ],
), ),
inapp( inapp(
@@ -456,7 +490,7 @@ const TEMPLATES = [
'{{characterName}} has mastered {{skill}}', '{{characterName}} has mastered {{skill}}',
'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.', 'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.',
'Read thy character', 'Read thy character',
'/player/uo/characters', PATHS.characters,
), ),
email( email(
@@ -469,7 +503,7 @@ const TEMPLATES = [
text('p1', text('p1',
'{{characterName}} has seen {{quest}} through to its end. It is written down, which is ' '{{characterName}} has seen {{quest}} through to its end. It is written down, which is '
+ 'more than most who set out on it can say.'), + 'more than most who set out on it can say.'),
button('cta', 'Read thy character', '/player/uo/characters'), button('cta', 'Read thy character', PATHS.characters),
], ],
), ),
inapp( inapp(
@@ -479,7 +513,7 @@ const TEMPLATES = [
'{{quest}} — concluded', '{{quest}} — concluded',
'{{characterName}} has seen {{quest}} through to its end. It is written down.', '{{characterName}} has seen {{quest}} through to its end. It is written down.',
'Read thy character', 'Read thy character',
'/player/uo/characters', PATHS.characters,
), ),
// ── The Chronicler of the Dead ────────────────────────────────────────── // ── The Chronicler of the Dead ──────────────────────────────────────────
@@ -508,7 +542,7 @@ const TEMPLATES = [
'{{characterName}} has fallen', '{{characterName}} has fallen',
'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.', 'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.',
'Read thy character', 'Read thy character',
'/player/uo/characters', PATHS.characters,
), ),
email( email(
@@ -532,7 +566,7 @@ const TEMPLATES = [
'{{characterName}} was murdered', '{{characterName}} was murdered',
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.', 'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.',
'Read thy character', 'Read thy character',
'/player/uo/characters', PATHS.characters,
), ),
// ── The keeper of the rolls ───────────────────────────────────────────── // ── The keeper of the rolls ─────────────────────────────────────────────
@@ -547,7 +581,7 @@ const TEMPLATES = [
'The roll of {{boardLabel}} is amended. {{standingLine}}'), 'The roll of {{boardLabel}} is amended. {{standingLine}}'),
text('p2', text('p2',
'A roll is only ever the state of a thing on the day it was read.'), 'A roll is only ever the state of a thing on the day it was read.'),
button('cta', 'Read the roll', '/shard/points'), button('cta', 'Read the roll', PATHS.leaderboards),
], ],
), ),
inapp( inapp(
@@ -557,7 +591,7 @@ const TEMPLATES = [
'A new name heads {{boardLabel}}', 'A new name heads {{boardLabel}}',
'The roll of {{boardLabel}} is amended. {{standingLine}}', 'The roll of {{boardLabel}} is amended. {{standingLine}}',
'Read the roll', 'Read the roll',
'/shard/points', PATHS.leaderboards,
), ),
] ]
@@ -617,7 +651,30 @@ const RULES = [
// seconds; mailing them anyway is how a warning system teaches people to // seconds; mailing them anyway is how a warning system teaches people to
// ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this. // ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this.
delay_seconds: 900, delay_seconds: 900,
cancel_on: ['uo.house.collapsed'], // **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, max_sends_per_hour: 200,
}, },
{ {

View File

@@ -122,7 +122,7 @@ 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', { 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.' }, description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
@@ -156,6 +156,47 @@ const OWNED_ASSET = [
description: 'A whole detail line, assembled from the parts the frame actually carried.' }, 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.' },
],
},
{ {
id: 'uo.vendor.expiring', id: 'uo.vendor.expiring',
label: 'Your vendor is about to be dismissed', label: 'Your vendor is about to be dismissed',
@@ -189,7 +230,7 @@ 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”', { 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.' }, description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
@@ -388,7 +429,7 @@ 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', { 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.' }, description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
@@ -440,7 +481,7 @@ const SOCIAL_CIVIC = [
description: 'Your character\'s name, as the city knows it.' }, description: 'Your character\'s name, as the city knows it.' },
{ 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', { 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.' }, description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
@@ -462,7 +503,7 @@ 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', { 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.' }, description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
@@ -489,9 +530,16 @@ 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', { name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
description: 'The phase as a clause rather than as the wire\'s enum.' }, description: 'The phase as a clause rather than as the wire\'s enum.' },
@@ -522,7 +570,7 @@ 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)', { 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.' }, description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
@@ -546,7 +594,7 @@ 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)', { 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.' }, description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
@@ -569,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.' },
], ],
}, },
@@ -586,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.' },
], ],
}, },
@@ -655,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.' },
], ],
}, },
@@ -734,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

@@ -75,7 +75,7 @@ test('every template key a rule names exists — its own or core\'s', () => {
} }
}) })
test('the sixteen in-universe families have both channels; the nine plain ones have neither', () => { 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)) const own = new Set(seeds.TEMPLATES.map((t) => t.key))
let bespoke = 0 let bespoke = 0
for (const r of seeds.RULES) { for (const r of seeds.RULES) {
@@ -96,8 +96,8 @@ test('the sixteen in-universe families have both channels; the nine plain ones h
// letter from anybody. // letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`) assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
} }
assert.equal(bespoke, 16) assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 32) assert.equal(seeds.TEMPLATES.length, 34)
}) })
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => { test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
@@ -141,12 +141,23 @@ const LABELLED = [
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }], { kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
['uo.points.rank_changed', ['boardLabel', 'standingLine'], ['uo.points.rank_changed', ['boardLabel', 'standingLine'],
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }], { 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', () => { test('every label a body builds a sentence around is supplied by the mapper', () => {
for (const [triggerId, labels, frame] of LABELLED) { for (const [triggerId, labels, frame] of LABELLED) {
// A first frame is never a transition, so the upsert kinds need a prior one. // A first frame is never a transition, so the upsert kinds need a prior one.
engagement.mapShardEvent({ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }] }, tracker) engagement.mapShardEvent(
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
tracker,
)
const targets = engagement.mapShardEvent(frame, tracker) const targets = engagement.mapShardEvent(frame, tracker)
const target = targets.find((t) => t.triggerId === triggerId) const target = targets.find((t) => t.triggerId === triggerId)
assert.ok(target, `${triggerId} fired`) assert.ok(target, `${triggerId} fired`)
@@ -227,5 +238,5 @@ test('one rule group, and appending to it later would reach fresh installs only'
// rule has to edit a test whose name says what appending costs. // rule has to edit a test whose name says what appending costs.
assert.equal(seeds.RULE_GROUPS.length, 1) assert.equal(seeds.RULE_GROUPS.length, 1)
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1') assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
assert.equal(seeds.RULE_GROUPS[0].rules.length, 25) 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, 25) 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))), [])
}) })
@@ -448,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 {
@@ -455,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')
@@ -196,6 +197,35 @@ const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] ||
// "On hand: gold. Charged each period: gold." — which is what the render sweep // "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 // 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. // 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 detailLine = (parts) => {
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '') const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
@@ -239,6 +269,45 @@ const MAPPERS = {
}) })
return 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
}
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {} const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
out.push({ out.push({
@@ -246,6 +315,7 @@ 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), houseLabel: houseLabel(decayName(ev), ev.region, serial),
@@ -308,6 +378,7 @@ 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', shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
dismissalAt: fees.dismissalAt, dismissalAt: fees.dismissalAt,
@@ -322,7 +393,7 @@ const MAPPERS = {
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined], ['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined], ['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined], ['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
['Dismissal', fees.dismissalAt], ['Dismissal', readableTime(fees.dismissalAt)],
['Standing at', place(ev)], ['Standing at', place(ev)],
]), ]),
}), }),
@@ -470,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}` }),
}) })
}, },
@@ -504,6 +575,7 @@ const MAPPERS = {
// rewritten the day `city.update` gains a `previousGovernor` actor. // rewritten the day `city.update` gains a `previousGovernor` actor.
const civic = defined({ const civic = defined({
city: String(city), city: String(city),
governorsUrl: PATHS.governors,
governorName, governorName,
previousGovernorName: undefined, previousGovernorName: undefined,
inSuccessionTo: undefined, inSuccessionTo: undefined,
@@ -543,9 +615,11 @@ 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), 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( candidateNote: trailing(
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null, Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
@@ -566,6 +640,7 @@ 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),
@@ -604,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 ────────────────────────────────────────────────────────
@@ -659,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,
@@ -738,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,
@@ -799,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.
@@ -814,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