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