module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.
server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.
server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:
* Transitions. champ.update and city.update are full-state upserts, so without
a per-process tracker a sidecar reconnect reads as twenty spawns starting.
A FIRST sighting is never a transition.
* Thresholds. conditions.js compares a declared variable against a LITERAL, so
"within 24 hours of dismissal" is not expressible; and vendor.listing is a
sweep frame re-emitted on any price change, so per-frame would flood. The
crossing is tracked here and `hoursRemaining` is declared so an operator can
still narrow with `is at most`.
* The members audience. "The members of THIS guild" differs every firing, so
it travels on the envelope as recipientUserIds (Phase 6 decision 2).
**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.
Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).
coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.
39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.
Co-Authored-By: Claude <noreply@anthropic.com>
505 lines
24 KiB
JavaScript
505 lines
24 KiB
JavaScript
// ── 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, 24)
|
|
// 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 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, [])
|
|
})
|