// ── 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`) } // Eighteen since protocol 6: the champion FALLS, in the same crier's voice as // the champion walking, because they are one story told in two mails. assert.equal(bespoke, 18) assert.equal(seeds.TEMPLATES.length, 36) }) 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', 'damagerNote'] 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. // TWO groups since protocol 6, and the second one is this test's whole point // made concrete: `uo.champ.boss_killed` could not be appended to `triggers-v1`, // because a deployment that has already stamped that key would never have // received it. A new rule gets a new key. assert.equal(seeds.RULE_GROUPS.length, 2) assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1') assert.equal(seeds.RULE_GROUPS[0].rules.length, 26) assert.equal(seeds.RULE_GROUPS[1].key, 'champ-boss-killed-v1') assert.deepEqual(seeds.RULE_GROUPS[1].rules.map((r) => r.trigger_id), ['uo.champ.boss_killed']) // No rule belongs to two groups, and between them they are the whole set. const grouped = seeds.RULE_GROUPS.flatMap((g) => g.rules.map((r) => r.trigger_id)) assert.equal(new Set(grouped).size, grouped.length) assert.deepEqual([...grouped].sort(), seeds.RULES.map((r) => r.trigger_id).sort()) })