fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger #24

Merged
whitlocktech merged 1 commits from fix/engagement-live-walk-uo into edge 2026-09-01 12:32:56 +00:00
Member

The module's half of Phase 11b's acceptance walk. Needs website#179 (the cooldown key and the seed-rule ceiling); pairs with servuo-plugins# and docs#.

Four defects. The first two meant the flagship family did not work at all, and neither was visible in any test — which is the point of the walk.

1. Every owner-audienced trigger reached NOBODY

const link = await links.getByAccount(target.ownerAccount)
if (!link || link.user_id == null) return null      // ← userId

shardLinks.model.toSafe camel-cases the row on the way out. So houses, vendors, logins, unlinks, skills, quests, deaths and the governor's letter all resolved to null — and the failure is indistinguishable from the ordinary unlinked-account case, which this file treats as normal and deliberately does not log. On the rig: the Greatly transition reached the wire with ownerAcct: seed_002, the account was linked, and no outbox row appeared.

The test fake returned user_id and therefore agreed with the bug. shardStreams.test.js's fake next door — same model, and the path this file's own header says it copies — returns userId, and shardPush.js reads owner.userId. The fake is now built by running the real toSafe over a stubbed db row, so its shape is not a hand-written opinion that can drift from the model again.

2. uo.house.refreshed — the 26th trigger (decision 11)

uo.house.idoc_warning's seeded rule carries delay_seconds: 900 so a player who repairs the house inside the quarter-hour is never told it is in peril. §4.2a is explicit that a delay without a cancellation is just a late mail — and nothing could cancel it. cancel_on named uo.house.collapsed, the outcome where the warning is pointless, and nothing named the outcome where it is wrong, because the mapper returned early on every transition that was not a late stage. The wire had carried the transition all along.

The Office of Deeds' one non-warning letter
SUBJECT: The ledger is set right for “Millrace”

From the Office of Deeds

This office records that “Millrace”, held in thy name, has been refreshed and
stands in good repair. It stood in imminent danger of collapse.

No further notice will be sent concerning it. Should it fall into disrepair
again, thou wilt hear from us before the deed is touched.

Three things about it are load-bearing:

  • The same subjectKey as the warning. outboxDb.cancel matches on (rule, subject_key); a refresh carrying any other subject would cancel nothing.
  • It fires on Ageless as well as LikeNew, and Ageless is the common case. A condemned house cannot be refreshedBaseHouse.RefreshDecay() returns false for DecayType.Condemned — so the rescue is the owner logging back in. Their newest house then becomes AutoRefresh and reads Ageless; an older one becomes ManualRefresh and reads LikeNew. The live rig produced Ageless, and a mapper reading only LikeNew would have missed it.
  • The cancellation does not depend on this rule. cancel_on is read off the warning's rule, so an operator who wants the cancellation without the reassurance simply leaves this one off — which, every seeded rule shipping disabled, is what a fresh install already does.

Two independent mistakes with one symptom.

The prefix is the module ID, not the mount. registry.registerRoutes prefixes a module's client routes with <id>/ and nothing else, so the real paths are /uo/houses, /uo/market, /uo/leaderboards. Every declared example read /shard/… (from module.json's mounts) and every hard-coded href was one of /shard/… or /player/uo/houses — a route that does not exist at all. An example is what the template editor previews and test-sends with, so this was wrong in the operator's face as well as in the mail.

And no url variable was ever populated. All eight were declared, none was ever set by the mapper — so every button rendered with an empty href and dropped out of the text part entirely. The Office of Deeds' warning simply had no "Review thy holdings" line.

Both now read the new config/clientPaths.js, and two tests close it: every url example must name a route this module mounts, and every declared url variable must actually be supplied on the path that emits its trigger — driven off the declarations, so the next one added is covered the day it is declared.

4. A raw wire timestamp was signing off the Merchants' Guild's letter

On hand: 0 gold. Charged each period: 8757 gold. Periods remaining: 0. Dismissal: 2026-09-02T04:06:43.8397548Z.

Core has no interpolation filters by design, so a datetime renders as whatever string the payload holds. Same argument place() and detailLine() already make: the readable form is assembled in the mapper and arrives as its own value. The machine value staysdismissalAt and autoPickAt are what an operator writes is at most conditions against — so autoPickWhen is an additional optional beside autoPickAt, not a replacement.

Also: a latent flake

hoursRemaining floors a live clock, so FEES(20) asserted 19 only if a millisecond had passed since the fixture was built. It went green or red on scheduling. Now FEES(20.5)20, deterministic for the next half hour.

Verification

  • 527 module tests green, 0 failing (3 new); the owner-resolution fix verified by reverting it (3 failures).
  • Proved end to end against real ServUO + the release sidecar + a live SMTP catcher: a Greatly transition produced one notification to the linked owner on both channels and nothing to a second linked-and-subscribed player; the owner logging back in cancelled both pending rows and delivered the letter above; the governor's letter arrived with a working /uo/governors link.

AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

The module's half of Phase 11b's acceptance walk. Needs **website#179** (the cooldown key and the seed-rule ceiling); pairs with **servuo-plugins#** and **docs#**. Four defects. The first two meant the flagship family did not work at all, and neither was visible in any test — which is the point of the walk. ## 1. Every `owner`-audienced trigger reached NOBODY ```js const link = await links.getByAccount(target.ownerAccount) if (!link || link.user_id == null) return null // ← userId ``` `shardLinks.model.toSafe` camel-cases the row on the way out. So houses, vendors, logins, unlinks, skills, quests, deaths and the governor's letter all resolved to `null` — and the failure is **indistinguishable from the ordinary unlinked-account case**, which this file treats as normal and deliberately does not log. On the rig: the `Greatly` transition reached the wire with `ownerAcct: seed_002`, the account was linked, and no outbox row appeared. **The test fake returned `user_id` and therefore agreed with the bug.** `shardStreams.test.js`'s fake next door — same model, and the path this file's own header says it copies — returns `userId`, and `shardPush.js` reads `owner.userId`. The fake is now built by running the **real `toSafe`** over a stubbed db row, so its shape is not a hand-written opinion that can drift from the model again. ## 2. `uo.house.refreshed` — the 26th trigger (decision 11) `uo.house.idoc_warning`'s seeded rule carries `delay_seconds: 900` so a player who repairs the house inside the quarter-hour is never told it is in peril. §4.2a is explicit that a delay without a cancellation is just a late mail — and nothing could cancel it. `cancel_on` named `uo.house.collapsed`, the outcome where the warning is *pointless*, and nothing named the outcome where it is *wrong*, because the mapper returned early on every transition that was not a late stage. **The wire had carried the transition all along.** <details><summary>The Office of Deeds' one non-warning letter</summary> ``` SUBJECT: The ledger is set right for “Millrace” From the Office of Deeds This office records that “Millrace”, held in thy name, has been refreshed and stands in good repair. It stood in imminent danger of collapse. No further notice will be sent concerning it. Should it fall into disrepair again, thou wilt hear from us before the deed is touched. ``` </details> Three things about it are load-bearing: - **The same `subjectKey` as the warning.** `outboxDb.cancel` matches on `(rule, subject_key)`; a refresh carrying any other subject would cancel nothing. - **It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case.** A condemned house *cannot be refreshed* — `BaseHouse.RefreshDecay()` returns false for `DecayType.Condemned` — so the rescue is the owner logging back in. Their newest house then becomes `AutoRefresh` and reads **`Ageless`**; an older one becomes `ManualRefresh` and reads `LikeNew`. The live rig produced `Ageless`, and a mapper reading only `LikeNew` would have missed it. - **The cancellation does not depend on this rule.** `cancel_on` is read off the *warning's* rule, so an operator who wants the cancellation without the reassurance simply leaves this one off — which, every seeded rule shipping disabled, is what a fresh install already does. ## 3. Every call-to-action in every in-universe body was a dead link Two independent mistakes with one symptom. **The prefix is the module ID, not the mount.** `registry.registerRoutes` prefixes a module's client routes with `<id>/` and nothing else, so the real paths are `/uo/houses`, `/uo/market`, `/uo/leaderboards`. Every declared `example` read `/shard/…` (from `module.json`'s `mounts`) and every hard-coded href was one of `/shard/…` or `/player/uo/houses` — a route that does not exist at all. An `example` is what the template editor previews and test-sends with, so this was wrong in the operator's face as well as in the mail. **And no `url` variable was ever populated.** All eight were declared, none was ever set by the mapper — so every button rendered with an empty href and dropped out of the text part entirely. The Office of Deeds' warning simply had no "Review thy holdings" line. Both now read the new `config/clientPaths.js`, and two tests close it: every `url` **example** must name a route this module mounts, and every declared `url` **variable** must actually be supplied on the path that emits its trigger — driven off the declarations, so the next one added is covered the day it is declared. ## 4. A raw wire timestamp was signing off the Merchants' Guild's letter > On hand: 0 gold. Charged each period: 8757 gold. Periods remaining: 0. Dismissal: **2026-09-02T04:06:43.8397548Z**. Core has no interpolation filters by design, so a `datetime` renders as whatever string the payload holds. Same argument `place()` and `detailLine()` already make: the readable form is assembled in the mapper and arrives as its own value. **The machine value stays** — `dismissalAt` and `autoPickAt` are what an operator writes `is at most` conditions against — so `autoPickWhen` is an additional optional beside `autoPickAt`, not a replacement. ## Also: a latent flake `hoursRemaining` floors a live clock, so `FEES(20)` asserted `19` only if a millisecond had passed since the fixture was built. It went green or red on scheduling. Now `FEES(20.5)` → `20`, deterministic for the next half hour. ## Verification - **527 module tests green, 0 failing** (3 new); the owner-resolution fix verified by reverting it (3 failures). - Proved end to end against real ServUO + the release sidecar + a live SMTP catcher: a `Greatly` transition produced one notification to the linked owner on **both** channels and nothing to a second linked-and-subscribed player; the owner logging back in **cancelled both pending rows** and delivered the letter above; the governor's letter arrived with a working `/uo/governors` link. --- AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 12:14:59 +00:00
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
849d4b10e8
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>
whitlocktech merged commit 17a96ed4c4 into edge 2026-09-01 12:32:56 +00:00
whitlocktech deleted branch fix/engagement-live-walk-uo 2026-09-01 12:32:57 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#24
No description provided.