feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 39s

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>
This commit is contained in:
2026-09-04 14:57:26 -05:00
parent cf60932c85
commit dc13515927
10 changed files with 501 additions and 64 deletions

View File

@@ -96,8 +96,10 @@ test('the seventeen in-universe families have both channels; the nine plain ones
// letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 34)
// 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', () => {
@@ -222,7 +224,7 @@ 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']
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`)
@@ -236,7 +238,17 @@ 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.
assert.equal(seeds.RULE_GROUPS.length, 1)
// 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())
})

View File

@@ -30,7 +30,11 @@ const one = (event) => {
// ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
assert.equal(TRIGGERS.length, 26)
// 27 since protocol 6: `uo.champ.boss_killed` joins the twenty-six §8.6 named.
// It is not one of the four carve-outs below being reinstated — it is a row the
// catalogue could not have, because until protocol 6 the wire had no kind for a
// boss defeat and the inference from `champ.update` was not good enough to mail.
assert.equal(TRIGGERS.length, 27)
// 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.
@@ -113,6 +117,10 @@ test('every url variable a body can interpolate is actually SUPPLIED', () => {
'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 })],
// Protocol 6. A single frame, unlike its two neighbours: a defeat is an
// EVENT on the wire rather than a change spotted between two snapshots, which
// is the whole reason the kind was worth a protocol bump.
'uo.champ.boss_killed': bossKilled(),
'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' },
@@ -320,6 +328,21 @@ test('the pre-decision attempt kind is not mapped at all', () => {
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
// Protocol 6. The spawn serial matches `champ`'s, so the pair can be walked as
// one altar's story: the boss goes up, then it comes down.
const bossKilled = (over) => ({
kind: 'champ.boss.killed',
serial: '0x40012345',
bossSerial: '0x901', category: 'champion', boss: 'Semidar', bossType: 'Semidar',
map: 'Felucca', x: 5187, y: 570, region: 'Destard',
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
damagers: [
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true, damage: 900 },
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
],
...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
@@ -339,6 +362,64 @@ test('champ.remove forgets the spawn, so its next appearance is a first sighting
assert.deepEqual(ids(champ({ active: true })), [])
})
// ── champ.boss.killed (Protocol 6) ─────────────────────────────────────────
test('a defeat fires on the frame itself, with no baseline to compare against', () => {
// Unlike its two neighbours above. `champ.update` is a SNAPSHOT, so a first
// sighting can never be a transition; a defeat is an event, so a first sighting
// is exactly the thing being reported.
const hit = one(bossKilled())
assert.equal(hit.triggerId, 'uo.champ.boss_killed')
assert.equal(hit.data.bossName, 'Semidar')
assert.equal(hit.data.killerName, 'Aldric')
assert.equal(hit.data.damagerCount, 2)
assert.equal(hit.data.damagerNote, ' 2 players fought it.')
assert.equal(hit.data.location, 'Felucca 5187, 570 (Destard)')
})
test('the subject is the SPAWN, so boss_up and boss_killed share one cooldown subject', () => {
map(champ({ active: true, bossUp: false }))
const up = one(champ({ active: true, bossUp: true }))
const down = one(bossKilled())
assert.equal(up.triggerId, 'uo.champ.boss_up')
assert.equal(down.data.spawnSerial, up.data.spawnSerial)
})
test('a defeat the shard could not attribute to an altar stands on the boss itself', () => {
// The sweep learns which altar a champion belongs to; a boss that popped and
// died between two sweeps arrives with no `serial`. A subject that exists once
// is all a cooldown needs, so the boss's own serial stands in rather than the
// firing being dropped.
const hit = one(bossKilled({ serial: undefined }))
assert.equal(hit.data.spawnSerial, '0x901')
})
test('a defeat clears the tracker, so the next boss on that altar is a transition again', () => {
map(champ({ active: true, bossUp: false }))
map(champ({ active: true, bossUp: true })) // fires boss_up
map(bossKilled())
// Without the tracker reset this would emit nothing: the tracker would still
// believe a boss is up, so the next one would not look like a change.
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
})
test('the damage TABLE never becomes trigger data, only its size', () => {
// `damagers` is `staff` in the visibility config. A trigger variable is
// interpolated into mail an operator may address to every subscriber, so a
// damager name reaching `data` would undo that field rule one layer up.
const hit = one(bossKilled())
const rendered = JSON.stringify(hit.data)
assert.equal(rendered.includes('Bran'), false, 'no damager name reaches the data')
assert.equal(rendered.includes('seed_003'), false, 'no damager account reaches the data')
assert.equal(hit.data.damagers, undefined)
})
test('an unattributed kill renders no damager sentence rather than an empty one', () => {
const hit = one(bossKilled({ damagers: [] }))
assert.equal(hit.data.damagerCount, undefined)
assert.equal(hit.data.damagerNote, undefined)
})
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
test('a governor change is a transition, and never on first sight', () => {

View File

@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
assert.equal('webId' in out.leader, false)
})
// ── Protocol 6: the champion defeat ──────────────────────────────────
const KILL = {
kind: 'champ.boss.killed',
serial: '0x40012345',
boss: 'Semidar',
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
damagers: [
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
],
}
test('the kill is public and its damage table is not', () => {
const config = visibility.compileDefaults()
// The whole shape of this addition in one assertion: a champion falling is
// content the public board is FOR, and a ranked roll of who was strong enough
// to fell it is a performance record nobody published on purpose.
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
for (const level of ['anonymous', 'logged_in', 'player']) {
const out = visibility.projectFeature('champs', KILL, level, config)
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
}
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
})
test('the killer rides the frame the way mob.killed already publishes one', () => {
// Deliberately NOT a configurable field. It is one actor, announced in-game to
// everyone present, and the same disclosure the public activity feed has made
// through `mob.killed` since before this framework existed.
const config = visibility.compileDefaults()
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.killer.name, 'Aldric')
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
})
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
// actor object like any other. An admin who opens the table to everyone has
// published character names, which is what they chose; they have not published
// account names, which is not theirs to choose.
const config = visibility.compileDefaults()
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.damagers.length, 2)
assert.equal(out.damagers[0].name, 'Aldric')
assert.equal(out.damagers[0].damage, 900)
assert.equal('acct' in out.damagers[0], false)
assert.equal('webId' in out.damagers[0], false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
@@ -360,10 +412,21 @@ const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
// inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
// already anonymous, so the KIND is public — while the `damagers` table on it is
// `staff` by field rule. That split is the point: a shard announces that its
// champion fell without publishing a roll of who was strong enough to fell it.
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
[
...PRE_V3_PUBLIC_KINDS,
...V3_ADDED_PUBLIC_KINDS,
...V4_ADDED_PUBLIC_KINDS,
...V6_ADDED_PUBLIC_KINDS,
].sort(),
)
})

View File

@@ -133,30 +133,57 @@ test('a broadcast spends the one budget dimension the module declares', () => {
assert.equal(byId('uo.news.post').cost, undefined)
})
// ── uo.broadcast: attempted exactly once ───────────────────────────────────
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
test('a broadcast is never retried, whatever the sidecar says', async () => {
test('a broadcast is retried on a transient failure and never on a permanent one', async () => {
const broadcast = byId('uo.broadcast')
// Every failure this transport can produce: no route to the sidecar, a data
// refusal, a bad token, a protocol mismatch, a shard that is not connected and
// a shard that timed out. The last two are genuinely transient, and this is
// the trade being taken knowingly — a lost announcement is cheaper than one
// delivered twice to everyone online.
for (const status of [0, 400, 401, 403, 409, 503, 504]) {
// Wave 1 asserted the opposite of this — every failure terminal, including the
// two that are plainly transient — because nothing on the wire could stop a
// retry announcing to everyone twice. Protocol 6 puts an idempotency key on the
// command and the shard refuses the repeat, so the trade that test recorded is
// no longer one that has to be made.
//
// 425 is the new status in this list: `bridge.busy`, the shard saying a command
// under this key is still in flight. Transient by construction.
const TRANSIENT = new Set([0, 425, 503, 504])
for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) {
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, false, `a ${status} must not be retried`)
// The clause belongs only where a retry was genuinely given up. On a
// permanent status it would explain the wrong thing.
if (!actions.PERMANENT_STATUSES.has(status)) {
assert.match(result.error, /announce twice/, 'a discarded retry must say why')
} else {
assert.doesNotMatch(result.error, /announce twice/, `a ${status} was never retryable`)
}
assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`)
}
})
test('every write carries the step idempotency key, unchanged', async () => {
// The key is what makes the retry above safe, so a verb that dropped it would
// silently restore the wave-1 hazard while every other assertion still passed.
// Asserted per verb rather than once, because each builds its own body.
const KEY = 'a'.repeat(40)
const seen = {}
uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } }
uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } }
uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } }
await byId('uo.broadcast').perform({
runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false,
})
await byId('uo.towncrier.post').perform({
runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false,
})
await byId('uo.news.post').perform({
runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false,
})
assert.equal(seen.broadcast.idempotencyKey, KEY)
assert.equal(seen.crier.idempotencyKey, KEY)
assert.equal(seen.news.idempotencyKey, KEY)
// The two keyed verbs post under an id DERIVED from the key. Both travel: the
// id is what makes a repeat replace, the key is what stops it re-announcing.
assert.equal(seen.crier.id, `evt-${KEY}`)
assert.equal(seen.news.id, `evt-${KEY}`)
})
test("the shard's own words reach the run log, not just a status code", async () => {
// **The rig found this.** The sidecar refuses a broadcast with
// `{"reason":"admin write plane disabled"}` and `legError` looks for