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
This commit is contained in:
2026-09-01 12:32:55 +00:00
6 changed files with 403 additions and 47 deletions

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

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".' },
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
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.' },
{ 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.' },
@@ -156,6 +156,47 @@ const OWNED_ASSET = [
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',
label: 'Your vendor is about to be dismissed',
@@ -189,7 +230,7 @@ const OWNED_ASSET = [
description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
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.' },
{ 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.' },
@@ -388,7 +429,7 @@ const SOCIAL_CIVIC = [
// is optional because a member the sweep never saw has no row there.
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
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.' },
{ 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.' },
@@ -440,7 +481,7 @@ const SOCIAL_CIVIC = [
description: 'Your character\'s name, as the city knows it.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
{ 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.' },
@@ -462,7 +503,7 @@ const SOCIAL_CIVIC = [
description: 'The new governor.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
{ 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.' },
@@ -489,9 +530,16 @@ const SOCIAL_CIVIC = [
description: 'Which phase opened: nominate or vote.' },
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
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,
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.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
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.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
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.' },
{ 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.' },
@@ -546,7 +594,7 @@ const COME_ONLINE = [
description: 'The boss, when the shard names it.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
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.' },
{ 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.' },
@@ -569,7 +617,7 @@ const COME_ONLINE = [
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
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.' },
],
},
@@ -586,7 +634,7 @@ const COME_ONLINE = [
description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true,
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.' },
],
},
@@ -655,7 +703,7 @@ const STAFF_FACING = [
description: 'What they wrote.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
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.' },
],
},
@@ -734,7 +782,7 @@ const OPERATOR_FACING = [
description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up',
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.' },
],
},

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))
let bespoke = 0
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.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
assert.equal(bespoke, 16)
assert.equal(seeds.TEMPLATES.length, 32)
assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 34)
})
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' }],
['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' }] }, 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 target = targets.find((t) => t.triggerId === triggerId)
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.
assert.equal(seeds.RULE_GROUPS.length, 1)
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
assert.equal(seeds.RULE_GROUPS[0].rules.length, 25)
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 { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS } = require('../config/clientPaths')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
@@ -29,7 +30,7 @@ const one = (event) => {
// ── The catalogue itself ───────────────────────────────────────────────────
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
// 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.
@@ -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.
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
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.data.stage, 'Greatly')
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
// A house being refreshed is the normal case. Mailing it would make the
// warning worthless.
assert.deepEqual(ids({ ...DECAY, to: 'LikeNew' }), [])
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
// not news, and mailing it would make the warning worthless.
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', () => {
const withSchedule = one({
...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
// check a vendor inside the window mails its owner every time somebody
// 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.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(18))), [])
})
@@ -448,6 +535,26 @@ test('an unmapped kind and a malformed frame both produce nothing', () => {
// ── 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 = {}) {
const emitted = []
return {
@@ -455,7 +562,11 @@ function deps(over = {}) {
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
tracker,
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] : []),
...over.shardLinks,
},

View File

@@ -50,6 +50,7 @@
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const { TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS, guildPath } = require('../config/clientPaths')
const core = require('../core')
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
// 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
@@ -239,6 +269,45 @@ const MAPPERS = {
})
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
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
out.push({
@@ -246,6 +315,7 @@ const MAPPERS = {
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
stage: ev.to,
houseLabel: houseLabel(decayName(ev), ev.region, serial),
@@ -308,6 +378,7 @@ const MAPPERS = {
ownerAccount: ev.ownerAcct,
data: defined({
vendorSerial: serial,
marketUrl: PATHS.market,
shopName: ev.shopName || undefined,
shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
dismissalAt: fees.dismissalAt,
@@ -322,7 +393,7 @@ const MAPPERS = {
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
['Dismissal', fees.dismissalAt],
['Dismissal', readableTime(fees.dismissalAt)],
['Standing at', place(ev)],
]),
}),
@@ -470,7 +541,7 @@ const MAPPERS = {
// already left, so there is nothing for the shard to attribute. The name is
// looked up from the roster mirror in `dispatch`.
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.
const civic = defined({
city: String(city),
governorsUrl: PATHS.governors,
governorName,
previousGovernorName: undefined,
inSuccessionTo: undefined,
@@ -543,9 +615,11 @@ const MAPPERS = {
triggerId: 'uo.election.opened',
data: defined({
city: String(city),
governorsUrl: PATHS.governors,
phase,
phaseLabel: phaseLabel(phase),
autoPickAt: ev.autoPickAt,
autoPickWhen: readableTime(ev.autoPickAt),
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
candidateNote: trailing(
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
@@ -566,6 +640,7 @@ const MAPPERS = {
const base = defined({
spawnSerial: serial,
champsUrl: PATHS.champs,
spawnName: ev.name || ev.type || 'a champion spawn',
category: ev.category || undefined,
location: place(ev),
@@ -604,20 +679,20 @@ const MAPPERS = {
if (wasUp === true) return
out.push({
triggerId: 'uo.server.up',
data: defined({ shardName: ev.shard || undefined }),
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
})
},
'server.shutdown': (ev, tracker, out) => {
if (tracker.serverUp === false) return
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) => {
if (tracker.serverUp === false) return
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 ────────────────────────────────────────────────────────
@@ -659,6 +734,7 @@ const MAPPERS = {
out.push({
triggerId: 'uo.page.new',
data: defined({
pagesUrl: PATHS.ops,
pageType: String(ev.type || 'Other'),
senderName: actorName(ev.sender),
message: ev.message || undefined,
@@ -738,6 +814,7 @@ const MAPPERS = {
out.push({
triggerId: 'uo.economy.milestone',
data: defined({
economyUrl: PATHS.shard,
metric,
value: Math.round(value),
threshold: crossed,
@@ -799,10 +876,16 @@ async function resolveTarget(target, deps) {
// `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
// 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) {
const link = await links.getByAccount(target.ownerAccount)
if (!link || link.user_id == null) return null
return { data, ownerUserId: Number(link.user_id) }
if (!link || link.userId == null) return null
return { data, ownerUserId: Number(link.userId) }
}
// `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)
if (!house || !house.ownerAcct) return null
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.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