fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Some checks failed
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 36s
PR Checks / client-build (pull_request) Successful in 8m17s

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:
2026-09-01 07:12:29 -05:00
parent 52d9c3ddb8
commit 849d4b10e8
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 }