// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ───── // // Two halves, tested separately for the reason the file splits them: `mapShardEvent` // is pure given a tracker and needs no database, and `fromShardEvent` is the half // that resolves an account into a person and therefore does. // // What is asserted here is deliberately not "each field is copied". It is the // three things a rule cannot express and a plain mapping would get wrong — // transitions, thresholds, and who an event is ABOUT — plus the four places §8.6 // or the protocol docs say the obvious implementation is the wrong one. const { test, beforeEach } = require('node:test') const assert = require('node:assert/strict') const engagement = require('../utils/shardEngagement') const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers') let tracker beforeEach(() => { tracker = engagement.createTracker() }) const map = (event) => engagement.mapShardEvent(event, tracker) const ids = (event) => map(event).map((t) => t.triggerId) const one = (event) => { const out = map(event) assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`) return out[0] } // ── The catalogue itself ─────────────────────────────────────────────────── test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => { assert.equal(TRIGGERS.length, 25) // 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. for (const carved of [ 'uo.market.item_listed', // a saved SEARCH; no per-user query store exists 'uo.guild.joined', // core's team.member.joined already fires for it 'uo.link.requested', // no addressable recipient, and a ~5-minute TTL ]) { assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`) } // Every id is this module's, which is what `namespaced()` enforces at // registration — asserted here too so the failure names the id rather than // arriving as a boot error. for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`) }) test('every variable carries an example, because a template is previewed with it', () => { for (const t of TRIGGERS) { for (const v of t.variables) { assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`) assert.ok(v.description, `${t.id}.${v.name} has a description`) } // A subjectKey that is not one of the trigger's own variables is refused at // registration; catching it here names the trigger instead of the boot. if (t.subjectKey) { assert.ok( t.variables.some((v) => v.name === t.subjectKey), `${t.id} subjectKey "${t.subjectKey}" is one of its variables`, ) } } }) test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => { for (const t of TRIGGERS) { for (const v of t.variables.filter((x) => x.type === 'url')) { assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`) // Not protocol-relative: `//evil.test/x` passes an "is it rooted" check. assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-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') assert.equal(cheat.ceiling, 'staff') assert.equal(cheat.audience, 'staff') // The three operator-facing ones sit a rung lower still: `staff` means admin, // editor AND moderator, so a digest of what moderators did must not ceiling there. for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) { assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`) } }) // ── Houses ───────────────────────────────────────────────────────────────── const DECAY = { kind: 'house.decay', serial: '0x400142F9', from: 'Fairly', to: 'Greatly', name: 'Millrace', ownerAcct: 'seed_002', region: 'Britain', map: 'Felucca', x: 1480, y: 1600, lastRefreshed: '2026-08-25T17:21:14Z', } test('a late decay stage warns the owner; an early one says nothing', () => { const t = one(DECAY) assert.equal(t.triggerId, 'uo.house.idoc_warning') 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' }), []) assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), []) }) test('the v5 schedule rides along when present and is simply absent when not', () => { const withSchedule = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z', estimatedCollapse: '2026-09-06T20:33:15Z', }, }) assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z') assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z') // **A dynamic-decay shard omits `estimatedCollapse` at every stage before // IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit // that absence means "not knowable", never "not yet read" — so the mapper must // pass the absence through rather than computing a fallback, which would // republish exactly the guess the shard refused to make. const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } }) assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z') assert.equal('estimatedCollapse' in dynamic.data, false) const v4 = one(DECAY) assert.equal('nextStage' in v4.data, false) assert.equal('estimatedCollapse' in v4.data, false) }) test('Collapsed is its own trigger, not a louder warning', () => { const t = one({ ...DECAY, to: 'Collapsed' }) assert.equal(t.triggerId, 'uo.house.collapsed') assert.equal(t.ownerAccount, 'seed_002') }) test('house.remove carries only a serial, so the owner is looked up later', () => { const t = one({ kind: 'house.remove', serial: '0x400142F9' }) assert.equal(t.triggerId, 'uo.house.collapsed') assert.equal(t.ownerAccount, undefined) assert.equal(t.houseSerial, '0x400142F9') }) // ── Vendors: the threshold, and the two ways there is nothing to warn about ── const listing = (fees) => ({ kind: 'vendor.listing', serial: '0x40001234', shopName: "Darrow's Bargains", ownerAcct: 'darrow_acct', location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' }, ...(fees === undefined ? {} : { fees }), }) const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString() const FEES = (h) => ({ exempt: false, newVendorSystem: true, chargePerPeriod: 10548, funds: 8204, payIntervalSec: 86400, periodsRemaining: 1, dismissalAt: inHours(h), }) 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))) 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.deepEqual(ids(listing(FEES(19))), []) assert.deepEqual(ids(listing(FEES(18))), []) }) test('a deposit that leaves the window re-arms the warning', () => { assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring']) assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in }) test('exempt and absent fees are both "nothing to warn about", not "no money"', () => { // A commission vendor has no PayTimer and is NEVER dismissed for fees. // Conflating that with a distant date is how a vendor that cannot expire ends // up in an expiry warning (docs/link/v5.md). assert.deepEqual(ids(listing({ exempt: true })), []) // A pre-v5 overlay sends no `fees` block at all. assert.deepEqual(ids(listing(undefined)), []) }) test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => { const t = one(listing(FEES(-3))) assert.equal(t.data.hoursRemaining, 0) }) test('an unowned listing is nobody to notify', () => { const { ownerAcct, ...anonymous } = listing(FEES(10)) assert.deepEqual(ids(anonymous), []) }) // ── Logins: the inversion protocol 5 exists to fix ───────────────────────── test('only a FAILED login warns — a successful one produces nothing', () => { const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' }) assert.equal(failed.triggerId, 'uo.account.login_failed') assert.equal(failed.data.reason, 'BadPass') assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), []) }) test('the pre-decision attempt kind is not mapped at all', () => { // `account.login.attempt` fires from a sink that runs BEFORE the auth decision // and whose args default `Accepted = true`, so a rule on it would have mailed a // security alert on every successful login. That is why v5 added a second kind // and why this one must stay unmapped. assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), []) }) // ── Transitions ──────────────────────────────────────────────────────────── const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over }) test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => { assert.deepEqual(ids(champ({ active: true })), []) assert.deepEqual(ids(champ({ active: true })), []) // still no change assert.deepEqual(ids(champ({ active: false })), []) assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started']) }) test('the boss is its own transition, tracked separately from active', () => { map(champ({ active: true, bossUp: false })) assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up']) assert.deepEqual(ids(champ({ active: true, bossUp: true })), []) }) test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => { map(champ({ active: false })) map({ kind: 'champ.remove', serial: '0x40012345' }) assert.deepEqual(ids(champ({ active: true })), []) }) const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over }) test('a governor change is a transition, and never on first sight', () => { assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), []) const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } })) assert.equal(t.triggerId, 'uo.governor.elected') assert.equal(t.data.governorName, 'Darrow') assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), []) }) test('an ELECTED governor with a linked account also gets a letter', () => { // Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a // person because `top[]` names a serial — and the same reasoning was silently // assumed to cover the governor. It does not: `BridgeJson.Actor()` writes // `acct` on every actor object, so the winner is addressable with no protocol // change. This test is the record of that, and of the decision that the // announcement and the letter are TWO triggers. map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } })) const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } })) assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed']) const letter = out[1] assert.equal(letter.ownerAccount, 'darrow') assert.equal(letter.data.city, 'Britain') assert.equal(letter.data.governorName, 'Darrow') // The bulletin carries no owner — it is the town's, not the governor's. assert.equal(out[0].ownerAccount, undefined) }) test('an UNLINKED governor still gets the town its announcement', () => { // Nobody to write to is an ordinary outcome, not an error — most game accounts // on most shards have never been linked — and it must not cost the city its // proclamation. map(city({ governor: { serial: '0x1', name: 'Mireille' } })) assert.deepEqual( ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), ['uo.governor.elected'], ) }) test('an election opening needs its deadline, or it does not fire', () => { map(city({ electionPhase: 'none' })) // **A "vote now" mail with nothing to act by is worse than none**, and // `autoPickAt` is declared required, so a phase change without one is dropped // here rather than refused by `emit` later. assert.deepEqual(ids(city({ electionPhase: 'vote' })), []) const fresh = engagement.createTracker() engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh) const out = engagement.mapShardEvent( city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }), fresh, ) assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened']) assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z') }) // ── The shard's own up/down, which is the cooldown table's stress test ───── test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => { // `server.hello` is sent on EVERY sidecar reconnect, not only on a shard // restart, which is exactly the flapping this trigger must not amplify. assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up']) assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), []) assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), []) }) test('down fires once per outage, and a crash is told apart from a clean stop', () => { map({ kind: 'server.hello', shard: 'UOMysticmoon' }) const down = one({ kind: 'server.shutdown' }) assert.equal(down.triggerId, 'uo.server.down') assert.equal(down.data.clean, true) assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down map({ kind: 'server.hello' }) assert.equal(one({ kind: 'server.crashed' }).data.clean, false) }) // ── Thresholds ───────────────────────────────────────────────────────────── const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts }) test('an economy milestone fires on a crossing, in both directions, never on first sight', () => { // A sidecar reconnect on a mature shard must not announce a line it crossed // months ago. assert.deepEqual(ids(supply(900_000_000)), []) const up = one(supply(1_200_000_000)) assert.equal(up.triggerId, 'uo.economy.milestone') assert.equal(up.data.direction, 'up') assert.equal(up.data.threshold, 1_000_000_000) assert.deepEqual(ids(supply(1_300_000_000)), []) // same band const down = one(supply(800_000_000)) assert.equal(down.data.direction, 'down') assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through }) // ── Leaderboards ─────────────────────────────────────────────────────────── const board = (serial, name) => ({ kind: 'points.board', system: 'QueensLoyalty', nameString: "Queen's Loyalty", top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }], }) test('a leaderboard change names the new leader and nobody personally', () => { assert.deepEqual(ids(board('0x1A2B', 'Darrow')), []) const t = one(board('0x1A2C', 'Bran')) assert.equal(t.triggerId, 'uo.points.rank_changed') assert.equal(t.data.leaderName, 'Bran') // The personal half is carved out: `top[]` names a mobile SERIAL and links are // keyed by ACCOUNT, so there is deliberately no owner on this target. assert.equal(t.ownerAccount, undefined) assert.deepEqual(ids(board('0x1A2C', 'Bran')), []) }) // ── Milestones ───────────────────────────────────────────────────────────── test('only a capped skill is a milestone', () => { const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' } assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), []) const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 }) assert.equal(t.triggerId, 'uo.skill.capped') assert.equal(t.ownerAccount, 'seed_000') // A mobile with no account is nobody's character. assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), []) }) test('both deaths address the victim, never the killer', () => { const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' } const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' } const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } }) assert.equal(death.ownerAccount, 'seed_000') assert.equal(death.data.killerName, 'an ogre lord') const murder = one({ kind: 'player.murdered', victim, murderer }) assert.equal(murder.triggerId, 'uo.character.murdered') assert.equal(murder.ownerAccount, 'seed_000') assert.equal(murder.data.murdererName, 'Darrow') }) // ── Guilds ───────────────────────────────────────────────────────────────── test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => { const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }) assert.equal(left.triggerId, 'uo.guild.left') assert.equal(left.guildId, 1042) assert.equal(left.memberSerial, '0x77') assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded') // Core's `team.member.joined` already fires for this, on every roster // reconcile, because a UO guild IS a Team and this module is the provider. // A second trigger would be two mails for one join (§8.6). assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), []) }) // ── Staff and operator ───────────────────────────────────────────────────── test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => { const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 }) assert.equal(page.triggerId, 'uo.page.new') assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' }) assert.equal(cheat.triggerId, 'uo.cheat.detected') assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected assert.equal(cheat.data.account, 'seed_000') // but staff are told which account }) test('the three audit kinds fold into one operator trigger', () => { assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action']) assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action']) const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' }) assert.equal(admin.data.action, 'ban') assert.equal(admin.data.origin, 'web') }) test('world.save.after reports what it wrote', () => { const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 }) assert.equal(t.triggerId, 'uo.world.saved') assert.equal(t.data.items, 1482301) // `before` is a boundary, not news. assert.deepEqual(ids({ kind: 'world.save.before' }), []) }) // ── The guard ────────────────────────────────────────────────────────────── test('an unmapped kind and a malformed frame both produce nothing', () => { assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), []) assert.deepEqual(ids({ kind: 'region.enter' }), []) assert.deepEqual(engagement.mapShardEvent(null, tracker), []) assert.deepEqual(engagement.mapShardEvent({}, tracker), []) assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), []) }) // ── Resolution: the half that reaches the database ───────────────────────── function deps(over = {}) { const emitted = [] return { emitted, emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }), tracker, shardLinks: { getByAccount: async (acct) => (acct === 'seed_002' ? { account: acct, user_id: 7 } : null), userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []), ...over.shardLinks, }, shardState: { listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }], listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }], listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }], listGuildMemberAccounts: async () => ['seed_002'], ...over.shardState, }, } } test('an owner-keyed event resolves the game account to a website user', async () => { const d = deps() await engagement.fromShardEvent(DECAY, d) assert.equal(d.emitted.length, 1) assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning') assert.equal(d.emitted[0].envelope.ownerUserId, 7) }) test('an UNLINKED owner is nobody to notify, and that is not an error', async () => { // The common case on every shard: most game accounts have never been linked. const d = deps() await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d) assert.deepEqual(d.emitted, []) }) test('house.remove fills the owner and the name in from the registry mirror', async () => { const d = deps() await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d) assert.equal(d.emitted.length, 1) assert.equal(d.emitted[0].envelope.ownerUserId, 7) assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace') }) test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => { // §5.1a: "the members of THIS guild" is a different answer every firing, so a // saved segment cannot express it and the set travels on the envelope // (Phase 6, decision 2 — the mechanism the Team fan-out was built on). const d = deps() await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d) assert.equal(d.emitted.length, 1) assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9]) assert.equal(d.emitted[0].envelope.ownerUserId, undefined) // The two names the frames do not carry come from the mirrors. assert.equal(d.emitted[0].envelope.data.memberName, 'Bran') }) test('guild.remove names the guild from the board, because the frame carries only an id', async () => { const d = deps() await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d) assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand') assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH') }) test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => { const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } }) await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d) assert.deepEqual(d.emitted, []) }) test('a subscribers-shaped event needs no resolution at all', async () => { const d = deps() engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition await engagement.fromShardEvent(champ({ active: true }), d) assert.equal(d.emitted.length, 1) assert.equal(d.emitted[0].envelope.ownerUserId, undefined) assert.equal(d.emitted[0].envelope.recipientUserIds, undefined) }) test('a failing lookup costs that one target and never the ingest feed', async () => { const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } }) await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d)) assert.deepEqual(d.emitted, []) })