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

@@ -662,6 +662,53 @@ const MAPPERS = {
}
},
// Protocol 6. A boss defeat, which until now could only be GUESSED at from
// `champ.update` losing its `bossUp` — a signal that also fires when a spawn is
// reset by a GM, when a boss despawns, and when the sweep simply reconnects.
// This one fires on the death itself.
//
// **The subject is the SPAWN, so it matches `uo.champ.boss_up`'s.** A rule with
// a cooldown on one altar therefore counts a boss going up and that same boss
// coming down as the same subject, which is what an operator writing "not more
// than once an hour about Destard" means. A kill the shard could not attribute
// to an altar carries no spawn, so the boss's own serial stands in — it is a
// subject that exists exactly once, which is all a cooldown needs of it.
//
// **Damagers are not surfaced as variables.** The table is on the frame and it
// is `staff` in the visibility config; putting names into a trigger's data
// would route them into mail an operator can address to `subscribers`, which is
// the field rule undone one layer up. `damagerCount` is a number and says the
// thing worth saying: how many took part.
'champ.boss.killed': (ev, tracker, out) => {
const spawnSerial = ev.serial == null ? null : String(ev.serial)
const bossSerial = ev.bossSerial == null ? null : String(ev.bossSerial)
const subject = spawnSerial || bossSerial
if (!subject) return
// The board no longer has a boss on this altar. Kept in step with the sweep's
// own view so the next `champ.update` carrying `bossUp: true` is read as a
// transition rather than as more of the same.
if (spawnSerial) tracker.champBossUp.set(spawnSerial, false)
const damagers = Array.isArray(ev.damagers) ? ev.damagers : []
out.push({
triggerId: 'uo.champ.boss_killed',
data: defined({
spawnSerial: subject,
champsUrl: PATHS.champs,
bossName: ev.boss || ev.bossType || 'the champion',
category: ev.category || undefined,
location: place(ev),
atPlace: trailing(place(ev), (p) => ` at ${p}`),
killerName: actorName(ev.killer),
damagerCount: damagers.length || undefined,
damagerNote: trailing(damagers.length || null, (n) =>
n === 1 ? ' One player fought it.' : ` ${n} players fought it.`),
}),
})
},
'champ.remove': (ev, tracker) => {
if (ev.serial == null) return
tracker.champActive.delete(String(ev.serial))

View File

@@ -83,7 +83,27 @@ const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
// field on a champs frame that is about PEOPLE rather than about an altar.
//
// `damagers` is the ranked table of who fought the boss and for how much. It is
// the honest basis for "who slew the champion" and it is also a performance
// record of named players that nobody consented to publish, which is precisely
// the tension the ladder exists to let a shard resolve for itself. It defaults
// to `staff`: the kill is public (a champion falling is announced in-world and
// is the content the board is for), the roll of who did the damage is not. A
// shard that wants a public board lowers one rule.
//
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
// covers the whole table rather than a rule per column, and the columns here
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
// rule regardless of what this is set to.
//
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
// last, it is announced in-game to everyone present, and it is the same shape
// and the same disclosure `mob.killed` has published on the public activity
// feed since before this framework existed.
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
@@ -189,6 +209,10 @@ const KIND_FEATURE = new Map(
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
// Protocol 6. Without this line rule 2 would fail the new kind closed to
// admin-only — correct as a default, and wrong as an outcome: a champion
// falling is exactly what the public board is for.
'champ.boss.killed': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',

View File

@@ -11,6 +11,34 @@
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call.
//
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
//
// The three write helpers the event engine drives take an optional
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
// is what makes retrying a world write safe — before it, a lost acknowledgement
// and a command that never applied were the same event seen from here.
//
// **A key is a function of the caller's unit of work, never of the attempt.** The
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
// carries the same key and a different step never collides with it. Passing a
// fresh value per call would satisfy the type and defeat the entire mechanism.
//
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
// second removal of a town-crier entry or a news article is a no-op the shard is
// already happy to perform — and the sidecar builds those commands from the path
// rather than from a body, so carrying one would be a protocol change bought for
// a guarantee that already holds.
//
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
// what leaves the admin screens (which send none, being driven by a human who can
// see whether the thing happened) unchanged.
//
// One new status can now come back from a keyed write: **425**, the sidecar's
// mapping of `bridge.busy` — a command under this key is still in flight on the
// shard. It is transient and retryable, and `shardAnnounce.classify` already
// treats it so by falling through to its retry case.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client')
@@ -168,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce }) =>
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
@@ -189,8 +220,8 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>