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>
243 lines
12 KiB
JavaScript
243 lines
12 KiB
JavaScript
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
|
|
//
|
|
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
|
|
// file proves the content shipped alongside them is coherent — which is a
|
|
// different failure mode and a quieter one: a rule pointing at a template key
|
|
// that does not exist, or a body built around a variable nothing supplies, is
|
|
// invisible until somebody enables the rule and a person does not get a mail.
|
|
//
|
|
// The three properties worth asserting, none of which a hand run would catch:
|
|
//
|
|
// 1. **Every rule names a trigger this module declares, and a template that
|
|
// exists** — its own or core's nine generic keys.
|
|
// 2. **Every LABEL a body builds a sentence around is supplied on every path
|
|
// that emits its trigger.** This is the one that earns its keep. The
|
|
// fragments are declared `required: false` so a missing one can never
|
|
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
|
|
// and that leaves nothing at runtime to notice a mapper that forgot one.
|
|
// This test is what notices.
|
|
// 3. **The plain nine are plain** (decision 9). A security notice drifting
|
|
// into the in-universe register is exactly the change nobody would think to
|
|
// review, and it is the one with a real cost attached.
|
|
|
|
const { test, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const engagement = require('../utils/shardEngagement')
|
|
const seeds = require('../config/engagementSeeds')
|
|
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
|
|
|
let tracker
|
|
beforeEach(() => { tracker = engagement.createTracker() })
|
|
|
|
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
|
|
|
|
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
|
|
// property 1). Spelled out rather than imported: this module cannot require core,
|
|
// and a key disappearing from core is exactly the breakage worth failing on.
|
|
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
|
|
|
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
|
|
const PLAIN = new Set([
|
|
'uo.account.login_failed', 'uo.account.unlinked',
|
|
'uo.server.up', 'uo.server.down',
|
|
'uo.page.new', 'uo.cheat.detected',
|
|
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
|
|
])
|
|
|
|
// ── The shape of the set ───────────────────────────────────────────────────
|
|
|
|
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
|
|
const ruled = seeds.RULES.map((r) => r.trigger_id)
|
|
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
|
|
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
|
|
})
|
|
|
|
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
|
|
for (const r of seeds.RULES) {
|
|
// `enabled` is not set here at all — the registry forces 0 — so the
|
|
// assertion is that nobody added it. Q3's invariant, at the source.
|
|
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
|
|
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
|
|
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
|
|
}
|
|
})
|
|
|
|
test('every template key a rule names exists — its own or core\'s', () => {
|
|
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
|
for (const r of seeds.RULES) {
|
|
for (const [channel, key] of Object.entries(r.template_keys)) {
|
|
assert.ok(
|
|
own.has(key) || CORE_KEYS.has(key),
|
|
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
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) {
|
|
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
|
|
if (PLAIN.has(r.trigger_id)) {
|
|
// **Decision 9, as a check.** A security notice written as a letter is
|
|
// indistinguishable in register from the phishing mail it warns about.
|
|
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
|
|
continue
|
|
}
|
|
bespoke += 1
|
|
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
|
|
// Both channels in the same voice: one rule fires on both at once, and a
|
|
// player who reads the inbox item and then the mail must not meet two
|
|
// different narrators.
|
|
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
|
|
// The DIGEST stays core's. A day of events rolled into a list is not a
|
|
// letter from anybody.
|
|
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
|
}
|
|
assert.equal(bespoke, 17)
|
|
assert.equal(seeds.TEMPLATES.length, 34)
|
|
})
|
|
|
|
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
|
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
|
|
// is a genuinely confusing pair and the reason this is asserted rather than
|
|
// remembered. Caught at registration too, as a boot failure.
|
|
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
|
for (const t of seeds.TEMPLATES) {
|
|
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
|
|
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
|
|
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
|
|
}
|
|
})
|
|
|
|
test('an email body has a subject and an in-app body has none', () => {
|
|
for (const t of seeds.TEMPLATES) {
|
|
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
|
|
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
|
|
}
|
|
})
|
|
|
|
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
|
|
// One prebuilt image mails as any shard. An in-universe body is UO-specific
|
|
// and must still be shard-agnostic.
|
|
const json = JSON.stringify(seeds.TEMPLATES)
|
|
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
|
|
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
|
|
}
|
|
})
|
|
|
|
// ── The property the render sweep needed ───────────────────────────────────
|
|
|
|
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
|
|
// trailing ones that may legitimately be empty. A frame that exercises each.
|
|
const LABELLED = [
|
|
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
|
|
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
|
|
['uo.house.collapsed', ['houseLabel'],
|
|
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
|
|
['uo.vendor.sale', ['shopLabel', 'itemLine'],
|
|
{ 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' }], electionPhase: frame.electionPhase && 'none' },
|
|
tracker,
|
|
)
|
|
const targets = engagement.mapShardEvent(frame, tracker)
|
|
const target = targets.find((t) => t.triggerId === triggerId)
|
|
assert.ok(target, `${triggerId} fired`)
|
|
for (const label of labels) {
|
|
assert.ok(
|
|
target.data[label] !== undefined && target.data[label] !== '',
|
|
`${triggerId} supplies ${label} — a body builds a sentence around it`,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('a label is supplied even when every optional field is absent', () => {
|
|
// The case the render sweep modelled: a v4 overlay, a house with no name and
|
|
// no region. `houseLabel` falls back to the seal number, which is worse prose
|
|
// and better than "Be it known that , recorded to thy name".
|
|
const target = engagement.mapShardEvent(
|
|
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
|
|
tracker,
|
|
)[0]
|
|
assert.match(target.data.houseLabel, /0x40012345/)
|
|
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
|
|
// The detail line names only what the frame carried — "Recorded at: ." is the
|
|
// shape this avoids. The stage is always there, so the line is too; a house
|
|
// with no coordinates simply does not get the "Recorded at" half.
|
|
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
|
|
})
|
|
|
|
test('a detail line names only the parts the frame actually carried', () => {
|
|
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
|
|
const at = new Date(Date.now() + 3600_000).toISOString()
|
|
const target = engagement.mapShardEvent(
|
|
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
|
|
tracker,
|
|
)[0]
|
|
assert.equal(target.triggerId, 'uo.vendor.expiring')
|
|
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
|
|
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
|
|
})
|
|
|
|
// ── Trailing fragments ─────────────────────────────────────────────────────
|
|
|
|
test('a trailing fragment leads with its own space, or is absent entirely', () => {
|
|
// `{{slainBy}}.` must close as "has fallen." with no fragment and
|
|
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
|
|
// its leading space produces "has fallenat the hands of" and nothing would
|
|
// notice.
|
|
const withKiller = engagement.mapShardEvent(
|
|
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
|
|
tracker,
|
|
)[0]
|
|
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
|
|
|
|
const without = engagement.mapShardEvent(
|
|
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
|
|
tracker,
|
|
)[0]
|
|
assert.equal(without.data.slainBy, undefined)
|
|
})
|
|
|
|
test('every declared fragment carries an example that shows its own shape', () => {
|
|
// The `example` is what the template editor previews and test-sends with, so a
|
|
// trailing fragment whose example omits the leading space teaches an author the
|
|
// wrong thing about where to put one.
|
|
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
|
|
for (const t of TRIGGERS) {
|
|
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
|
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
|
}
|
|
}
|
|
})
|
|
|
|
// ── The group key ──────────────────────────────────────────────────────────
|
|
|
|
test('one rule group, and appending to it later would reach fresh installs only', () => {
|
|
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
|
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
|
// 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, 26)
|
|
})
|