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>
646 lines
31 KiB
JavaScript
646 lines
31 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')
|
|
const { PATHS } = require('../config/clientPaths')
|
|
|
|
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, 26)
|
|
// 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`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('a url example names a route this module actually mounts', () => {
|
|
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
|
|
// `mounts` — and the client router prefixes a module's routes with its **ID**
|
|
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
|
|
// over: the example is what the template editor previews and test-sends with,
|
|
// and `clientPaths.js` is now the single place both it and the bodies read.
|
|
const known = new Set(Object.values(PATHS))
|
|
for (const t of TRIGGERS) {
|
|
for (const v of t.variables.filter((x) => x.type === 'url')) {
|
|
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
|
|
const parent = v.example.replace(/\/[^/]+$/, '')
|
|
assert.ok(
|
|
known.has(v.example) || known.has(parent),
|
|
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('every url variable a body can interpolate is actually SUPPLIED', () => {
|
|
// The defect this exists for is invisible in the source and invisible in a
|
|
// fixture: a declared-but-never-populated optional interpolates to the empty
|
|
// string, so the letter renders perfectly and its call-to-action button has no
|
|
// href. Nine of the sixteen in-universe bodies shipped that way.
|
|
//
|
|
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
|
|
// added is covered the day it is declared.
|
|
const frames = {
|
|
'uo.house.idoc_warning': DECAY,
|
|
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
|
|
'uo.vendor.expiring': listing(FEES(20)),
|
|
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
|
|
// Two frames each: an upsert kind is never a transition on FIRST sight, so
|
|
// the tracker has to see a baseline before the change means anything.
|
|
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
|
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
|
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
|
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
|
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
|
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
|
'uo.server.down': { kind: 'server.shutdown' },
|
|
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
|
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
|
|
}
|
|
|
|
for (const t of TRIGGERS) {
|
|
const urls = t.variables.filter((v) => v.type === 'url')
|
|
if (!urls.length) continue
|
|
const frame = frames[t.id]
|
|
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
|
|
|
|
const fresh = engagement.createTracker()
|
|
let target = null
|
|
for (const f of Array.isArray(frame) ? frame : [frame]) {
|
|
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
|
|
if (hit) target = hit
|
|
}
|
|
assert.ok(target, `${t.id} did not fire for its frame`)
|
|
|
|
for (const v of urls) {
|
|
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
|
|
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-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)')
|
|
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
|
|
// not news, and mailing it would make the warning worthless.
|
|
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
|
|
})
|
|
|
|
test('a refresh is its own trigger, and it is what cancels the warning', () => {
|
|
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
|
|
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
|
|
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
|
|
// the decay sweep has always emitted this transition.
|
|
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
|
|
assert.equal(t.triggerId, 'uo.house.refreshed')
|
|
assert.equal(t.ownerAccount, 'seed_002')
|
|
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
|
|
// (rule, subject_key), so a different one would cancel nothing.
|
|
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
|
|
assert.equal(t.data.previousStage, 'Greatly')
|
|
// A TRAILING fragment: its own leading space, and empty rather than reading
|
|
// "It stood in decay." when the previous stage has no word of its own.
|
|
assert.equal(t.data.fromLine, ' It stood greatly worn.')
|
|
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
|
|
})
|
|
|
|
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.
|
|
// 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
|
|
// number the answer is 20 or 19 depending on whether a millisecond has passed
|
|
// since the fixture was built, and this assertion was flaking on exactly that.
|
|
const first = one(listing(FEES(20.5)))
|
|
assert.equal(first.triggerId, 'uo.vendor.expiring')
|
|
assert.equal(first.ownerAccount, 'darrow_acct')
|
|
assert.equal(first.data.hoursRemaining, 20)
|
|
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 ─────────────────────────
|
|
|
|
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
|
|
// one, taken FROM that model rather than written out here: the model's `toSafe`
|
|
// camel-cases the row, and a hand-written fake using the column names is a fake
|
|
// that will agree with a resolver reading the column names. Stubbing the db
|
|
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
|
|
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
|
|
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
|
|
|
function linkRow(account, userId) {
|
|
const realGet = shardLinksDb.getByAccount
|
|
shardLinksDb.getByAccount = async () => ({
|
|
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
|
|
})
|
|
try {
|
|
return shardLinksModel.getByAccount(account)
|
|
} finally {
|
|
shardLinksDb.getByAccount = realGet
|
|
}
|
|
}
|
|
|
|
function deps(over = {}) {
|
|
const emitted = []
|
|
return {
|
|
emitted,
|
|
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
|
tracker,
|
|
shardLinks: {
|
|
// Shaped by the REAL model's `toSafe`, not by the column names. A fake that
|
|
// returns `user_id` agrees with a resolver that reads `user_id`, and the
|
|
// pair passes while every owner-audienced trigger reaches nobody on a live
|
|
// shard — which is exactly what happened. `linkRow` below is the guard.
|
|
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 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, [])
|
|
})
|