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:
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user