From 849d4b10e80acd94ab56a7bc6754163abd98e1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 07:12:29 -0500 Subject: [PATCH] fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger Needs website# (the cooldown key and the seed-rule ceiling). 1. Every owner-audienced trigger reached NOBODY. `resolveTarget` read `link.user_id`; the model's `toSafe` returns `userId`. So the whole flagship family -- houses, vendors, logins, unlinks, deaths, the governor's letter -- resolved to null and looked exactly like the ordinary unlinked-account case, which the code treats as normal and deliberately does not log. The test fake returned `user_id` and therefore agreed with the bug, while `shardStreams.test.js`'s fake next door -- same model, the path this file says it copies -- returned `userId`. The fake is now built by running the real `toSafe` over a stubbed db row, so the shape is not a hand-written opinion. 2. `uo.house.refreshed`, the 26th trigger (the org lead's decision 11). The warning's rule carries `delay_seconds: 900` so a player who repairs the house inside the quarter-hour is never told it is in peril -- and nothing could cancel it: `cancel_on` named only the collapse. The wire had carried the transition all along; the mapper returned early on it. It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case: a condemned house cannot be refreshed at all (`RefreshDecay()` refuses `DecayType.Condemned`), so the rescue is the owner logging in, and their newest house then reads `Ageless`. Ships a body and a seeded (disabled) rule of its own; the cancellation is read off the WARNING's rule and works whether or not the new one is enabled. 3. Every call-to-action in every in-universe body was a dead link, from two independent mistakes. The client router prefixes a module's routes with its ID (`/uo/houses`), not with module.json's `mounts` (`/shard/...`), so every declared `example` was a 404 -- and an example is what the template editor previews and test-sends with. And no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href and dropped out of the text part entirely. Both now read `config/clientPaths.js`. Two tests close it. 4. A raw wire timestamp was signing off the Merchants' Guild's letter (`2026-09-02T04:06:43.8397548Z`, mid-sentence). Core has no interpolation filters by design, so the readable form is assembled in the mapper and arrives as its own variable; the machine value stays, because an operator writes `is at most` conditions against it. Also fixes a latent flake: `hoursRemaining` floors a live clock, so a fixture at a whole number asserted 19 or 20 depending on sub-millisecond timing. 527 module tests green (3 new). Proved end to end against real ServUO + the release sidecar + a live SMTP catcher; see docs#. Co-Authored-By: Claude --- server/config/clientPaths.js | 46 ++++++++++ server/config/engagementSeeds.js | 85 +++++++++++++++---- server/config/shardTriggers.js | 72 +++++++++++++--- server/test/engagementSeeds.test.js | 21 +++-- server/test/shardEngagement.test.js | 125 ++++++++++++++++++++++++++-- server/utils/shardEngagement.js | 101 ++++++++++++++++++++-- 6 files changed, 403 insertions(+), 47 deletions(-) create mode 100644 server/config/clientPaths.js diff --git a/server/config/clientPaths.js b/server/config/clientPaths.js new file mode 100644 index 0000000..15d3d94 --- /dev/null +++ b/server/config/clientPaths.js @@ -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 `/` 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 } diff --git a/server/config/engagementSeeds.js b/server/config/engagementSeeds.js index 51d9d01..7548123 100644 --- a/server/config/engagementSeeds.js +++ b/server/config/engagementSeeds.js @@ -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, }, { diff --git a/server/config/shardTriggers.js b/server/config/shardTriggers.js index 1ca47ac..5b73676 100644 --- a/server/config/shardTriggers.js +++ b/server/config/shardTriggers.js @@ -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.' }, ], }, diff --git a/server/test/engagementSeeds.test.js b/server/test/engagementSeeds.test.js index 339172c..a0f66ed 100644 --- a/server/test/engagementSeeds.test.js +++ b/server/test/engagementSeeds.test.js @@ -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) }) diff --git a/server/test/shardEngagement.test.js b/server/test/shardEngagement.test.js index 3011499..06aa935 100644 --- a/server/test/shardEngagement.test.js +++ b/server/test/shardEngagement.test.js @@ -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, }, diff --git a/server/utils/shardEngagement.js b/server/utils/shardEngagement.js index 0844dfb..13914ee 100644 --- a/server/utils/shardEngagement.js +++ b/server/utils/shardEngagement.js @@ -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