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

@@ -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,
},