The website's half of protocol 6. Every event-driven write now carries the step's idempotency key, and `uo.broadcast` stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything including a 503 from a shard that was merely restarting, with a comment naming the line that would change when the wire could refuse a repeat. This is that line: it defers to `sidecarFailure`, the same helper its two siblings already used, so the hand-rolled variant that forced every outcome terminal is gone rather than re-tuned. One verb was less idempotent than its own id made it look. Both keyed verbs post under a run-scoped id and a repeat replaces — but `news.add` with `announce: true` makes the criers proclaim the title on every post, so a retry replaced the article silently and proclaimed it again. The key stops the second proclamation. `champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail it closed to admin), with `damagers` a nested `staff` field rule: the kill is public because a champion falling is what the board is for, the ranked roll of who was strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger — which is what makes it usable as an event PHASE CONDITION, since a condition is written over a trigger firing — and it carries `damagerCount`, never a damager name, because a trigger variable reaches mail an operator may address to every subscriber. Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped once under a settings guard, so appending a 27th entry would have reached fresh installs and nothing else. It also ships email+inapp and NOT push, and the comment says why — no trigger in this module is also a registered stream, so no engagement rule here can push. That is pre-existing in twenty rules and flagged rather than fixed; this one declines to be the twenty-first. Co-Authored-By: Claude <noreply@anthropic.com>
255 lines
13 KiB
JavaScript
255 lines
13 KiB
JavaScript
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
|
|
//
|
|
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
|
|
// file proves the content shipped alongside them is coherent — which is a
|
|
// different failure mode and a quieter one: a rule pointing at a template key
|
|
// that does not exist, or a body built around a variable nothing supplies, is
|
|
// invisible until somebody enables the rule and a person does not get a mail.
|
|
//
|
|
// The three properties worth asserting, none of which a hand run would catch:
|
|
//
|
|
// 1. **Every rule names a trigger this module declares, and a template that
|
|
// exists** — its own or core's nine generic keys.
|
|
// 2. **Every LABEL a body builds a sentence around is supplied on every path
|
|
// that emits its trigger.** This is the one that earns its keep. The
|
|
// fragments are declared `required: false` so a missing one can never
|
|
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
|
|
// and that leaves nothing at runtime to notice a mapper that forgot one.
|
|
// This test is what notices.
|
|
// 3. **The plain nine are plain** (decision 9). A security notice drifting
|
|
// into the in-universe register is exactly the change nobody would think to
|
|
// review, and it is the one with a real cost attached.
|
|
|
|
const { test, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const engagement = require('../utils/shardEngagement')
|
|
const seeds = require('../config/engagementSeeds')
|
|
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
|
|
|
let tracker
|
|
beforeEach(() => { tracker = engagement.createTracker() })
|
|
|
|
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
|
|
|
|
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
|
|
// property 1). Spelled out rather than imported: this module cannot require core,
|
|
// and a key disappearing from core is exactly the breakage worth failing on.
|
|
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
|
|
|
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
|
|
const PLAIN = new Set([
|
|
'uo.account.login_failed', 'uo.account.unlinked',
|
|
'uo.server.up', 'uo.server.down',
|
|
'uo.page.new', 'uo.cheat.detected',
|
|
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
|
|
])
|
|
|
|
// ── The shape of the set ───────────────────────────────────────────────────
|
|
|
|
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
|
|
const ruled = seeds.RULES.map((r) => r.trigger_id)
|
|
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
|
|
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
|
|
})
|
|
|
|
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
|
|
for (const r of seeds.RULES) {
|
|
// `enabled` is not set here at all — the registry forces 0 — so the
|
|
// assertion is that nobody added it. Q3's invariant, at the source.
|
|
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
|
|
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
|
|
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
|
|
}
|
|
})
|
|
|
|
test('every template key a rule names exists — its own or core\'s', () => {
|
|
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
|
for (const r of seeds.RULES) {
|
|
for (const [channel, key] of Object.entries(r.template_keys)) {
|
|
assert.ok(
|
|
own.has(key) || CORE_KEYS.has(key),
|
|
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
|
|
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
|
let bespoke = 0
|
|
for (const r of seeds.RULES) {
|
|
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
|
|
if (PLAIN.has(r.trigger_id)) {
|
|
// **Decision 9, as a check.** A security notice written as a letter is
|
|
// indistinguishable in register from the phishing mail it warns about.
|
|
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
|
|
continue
|
|
}
|
|
bespoke += 1
|
|
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
|
|
// Both channels in the same voice: one rule fires on both at once, and a
|
|
// player who reads the inbox item and then the mail must not meet two
|
|
// different narrators.
|
|
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
|
|
// The DIGEST stays core's. A day of events rolled into a list is not a
|
|
// letter from anybody.
|
|
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
|
}
|
|
// 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())
|
|
})
|