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:
@@ -75,7 +75,7 @@ test('every template key a rule names exists — its own or core\'s', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('the sixteen in-universe families have both channels; the nine plain ones have neither', () => {
|
||||
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
|
||||
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||
let bespoke = 0
|
||||
for (const r of seeds.RULES) {
|
||||
@@ -96,8 +96,8 @@ test('the sixteen in-universe families have both channels; the nine plain ones h
|
||||
// letter from anybody.
|
||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||
}
|
||||
assert.equal(bespoke, 16)
|
||||
assert.equal(seeds.TEMPLATES.length, 32)
|
||||
assert.equal(bespoke, 17)
|
||||
assert.equal(seeds.TEMPLATES.length, 34)
|
||||
})
|
||||
|
||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||
@@ -141,12 +141,23 @@ const LABELLED = [
|
||||
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
|
||||
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
|
||||
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
|
||||
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
|
||||
// has a hole in it without one. It is `required: false` like the others and
|
||||
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
|
||||
// all unless the frame carried `autoPickAt`.
|
||||
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
|
||||
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
|
||||
['uo.house.refreshed', ['houseLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
|
||||
]
|
||||
|
||||
test('every label a body builds a sentence around is supplied by the mapper', () => {
|
||||
for (const [triggerId, labels, frame] of LABELLED) {
|
||||
// A first frame is never a transition, so the upsert kinds need a prior one.
|
||||
engagement.mapShardEvent({ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }] }, tracker)
|
||||
engagement.mapShardEvent(
|
||||
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
|
||||
tracker,
|
||||
)
|
||||
const targets = engagement.mapShardEvent(frame, tracker)
|
||||
const target = targets.find((t) => t.triggerId === triggerId)
|
||||
assert.ok(target, `${triggerId} fired`)
|
||||
@@ -227,5 +238,5 @@ test('one rule group, and appending to it later would reach fresh installs only'
|
||||
// rule has to edit a test whose name says what appending costs.
|
||||
assert.equal(seeds.RULE_GROUPS.length, 1)
|
||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 25)
|
||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user