Files
Module-uo/server/utils/shardEngagement.js
wtclaude dc13515927
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
feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
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>
2026-09-04 14:57:26 -05:00

1035 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Shard event → engagement trigger ───────────────────────────────────────
//
// ENGAGEMENT.md Phase 11. The third fan-out off `shardIngest.ingest`, beside the
// SSE broadcast and the push tickle, and the one that produces a PER-PERSON
// notification subject to a rule, a preference and a suppression. It is the twin
// of `shardPush.js` and reads deliberately like it:
//
// • `shardStreams.mapShardEvent` turns a frame into push targets;
// `mapShardEvent` here turns a frame into engagement events.
// • Owner resolution is why neither can be a pure mapper: an owner-keyed target
// names a GAME ACCOUNT, and turning that into a website user needs
// `shardLinks`. An unlinked account is simply nobody to notify.
//
// **Nothing here decides who is told.** It says what happened and (for an
// owner- or members-shaped event) who it is ABOUT; the engine applies the rules,
// the ceiling, the preferences and the suppression list. That split is the
// module boundary: a module cannot send mail (§1.2) and this is not the back door.
//
// **Never throws, never blocks ingest.** `ingest()` calls this fire-and-forget
// exactly as it calls the broadcast and the push dispatch, and every mapper below
// is wrapped so one bad frame cannot stop the feed. This is the same reason the
// C# side's `Emit()` enqueues and returns rather than touching the socket from the
// Core thread.
//
// ── Three things that are NOT a plain field mapping ────────────────────────
//
// Most of §8.6's rows are "read four fields off the frame and emit". Three are
// not, and each is here rather than in a rule because a rule cannot express it:
//
// 1. **Transitions.** `champ.update` and `city.update` are full-state UPSERTS
// re-emitted on any change, not discrete "started"/"elected" events. Without
// a per-process transition tracker, a reconnect snapshot is read as twenty
// champion spawns starting at once. `shardStreams.js` already solved this for
// push and this file uses the same shape — and the same rule that a FIRST
// sighting is never a transition.
// 2. **Thresholds.** `uo.vendor.expiring` and `uo.economy.milestone` fire when a
// value CROSSES a line. `conditions.js` compares a declared variable against
// a literal and has no relative-time or previous-value operator, so
// "within 24 hours of dismissal" and "gold passed a billion" are not
// expressible as conditions — and `vendor.listing` is a sweep frame
// re-emitted on every price change, so emitting per frame would flood. The
// crossing is tracked here; the operator still narrows with
// `hoursRemaining is at most N`.
// 3. **Audience resolution for `members`.** A guild event is about the members
// of THAT guild, which is a different answer for every firing and therefore
// cannot be a saved segment (whose params are constants). The access-checked
// set travels on the envelope as `recipientUserIds` — Phase 6's decision 2,
// and the mechanism the Team fan-out was built on.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const { TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS, guildPath } = require('../config/clientPaths')
const core = require('../core')
const log = core.logger('shard-engagement')
// ── Thresholds ─────────────────────────────────────────────────────────────
// When a vendor becomes "expiring". Hours rather than pay periods, because a pay
// period is a real day under the new vendor system and a UO day (~2 real hours)
// under the old one — the exact factor-of-twelve trap `docs/link/v5.md` records,
// and the reason the wire carries `dismissalAt` as an instant.
//
// 48 hours is one full real day of warning even on a shard whose owner logs in
// daily, and it is the OUTER edge: the mapper fires once on the way in, and the
// operator narrows further with `hoursRemaining is at most 24` if they want less.
const VENDOR_WARN_HOURS = 48
// Gold-supply reporting lines, ascending. Crossing one in either direction is one
// `uo.economy.milestone`. They are the module's rather than the operator's for
// now: an admin-configurable ladder is a settings surface, and this phase's job
// is the trigger. An operator who wants a different line writes a rule condition
// on `value`.
const GOLD_THRESHOLDS = [
100_000_000, 250_000_000, 500_000_000, 1_000_000_000,
2_500_000_000, 5_000_000_000, 10_000_000_000,
]
// The same, for account count.
const ACCOUNT_THRESHOLDS = [100, 250, 500, 1000, 2500, 5000, 10_000]
// Which line a value sits above, as an index. -1 means "below the first".
const bandOf = (value, thresholds) => {
let band = -1
for (let i = 0; i < thresholds.length; i += 1) if (value >= thresholds[i]) band = i
return band
}
// ── The transition tracker ─────────────────────────────────────────────────
/**
* Per-process state for the upsert kinds and the threshold kinds.
*
* Injectable so a test gets a fresh one; a module-level default backs the live
* dispatcher. It is deliberately NOT persisted: its whole job is to say "has
* this process seen a previous value", and a value restored from a database
* would make the first frame after a restart a transition against state the
* shard may have left behind hours ago.
*/
function createTracker() {
return {
champActive: new Map(), // spawn serial → boolean
champBossUp: new Map(), // spawn serial → boolean
cityGovernor: new Map(), // city → governor serial or null
cityPhase: new Map(), // city → electionPhase
vendorWarned: new Map(), // vendor serial → boolean (already inside the window)
pointsLeader: new Map(), // points system → leader serial
economyBand: new Map(), // metric → band index
serverUp: null, // boolean or null (never seen)
}
}
const defaultTracker = createTracker()
/** Reset the module-level tracker. For tests and for `shardIngest.reset()`. */
function reset() {
const fresh = createTracker()
for (const key of Object.keys(fresh)) defaultTracker[key] = fresh[key]
}
// ── Small shared shapes ────────────────────────────────────────────────────
// "Felucca 1480, 1600" — one string rather than four variables, because a
// template that has to assemble coordinates is a template every author gets
// slightly differently. Returns undefined when there is nothing to format, so it
// drops out of an optional variable rather than rendering "undefined , ".
function place(ev) {
const map = ev.map || (ev.location && ev.location.map)
const x = ev.x ?? (ev.location && ev.location.x)
const y = ev.y ?? (ev.location && ev.location.y)
if (!map && x == null) return undefined
const coords = x == null || y == null ? '' : ` ${x}, ${y}`
const region = ev.region || (ev.location && ev.location.region)
const suffix = region ? ` (${region})` : ''
return `${map || ''}${coords}${suffix}`.trim() || undefined
}
// An actor object's display name, whichever of the shard's shapes it arrives in.
const actorName = (actor) => (actor && typeof actor === 'object' ? actor.name : undefined) || undefined
const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct : undefined) || undefined
// Drop the undefined values before they reach `emit`. A declared OPTIONAL
// variable that arrives as `undefined` is dropped by `validatePayload` anyway,
// but building the object without them keeps the emit log's `variables` list
// honest about what the frame actually carried.
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
// ── Presentational fragments (ENGAGEMENT.md Phase 11b, decision 8) ─────────
//
// **A template has no conditionals, by design** (`interpolate.js`: no filters,
// no loops, no ternaries), and an unset optional interpolates to the EMPTY
// STRING. That is exactly right for `notify.event`, whose variables are
// structural — but the in-universe bodies are sentences, and a sentence with a
// hole in the middle of it reads as a bug: "The house , in , stands in peril."
//
// So the ternary stays at the call site and its RESULT arrives as a declared
// optional variable, which is Phase 5a's `forWhom` precedent unchanged. Two
// shapes, and the difference matters when you write one:
//
// • a LABEL always has a value, so it can carry a sentence's spine
// (`houseLabel` is a name, or a seal number when there is no name);
// • a TRAILING FRAGMENT may be empty and leads with its own space, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// Every one of them is declared `required: false` on the trigger with an
// `example` showing precisely what it produces, leading space included — which
// is what the template editor previews and test-sends with.
/** A trailing fragment, or undefined when there is nothing to say. */
const trailing = (value, build) => (value ? build(value) : undefined)
// "The Silver Anvil, in Britain" · "the house under seal 0x40001234". A house
// often has no name and sometimes no region, and the warning has to name
// SOMETHING the owner can act on — a seal number is worse prose and better than
// a blank.
const houseLabel = (name, region, serial) => {
const named = name ? `${name}` : `the house under seal ${serial}`
return region ? `${named}, in ${region}` : named
}
// The decay stages as words rather than as the wire's enum. `Greatly` in the
// middle of a sentence is the shard's vocabulary leaking into a letter.
const STAGE_WORDS = {
FAIRLY: 'fairly worn',
GREATLY: 'greatly worn',
IDOC: 'in imminent danger of collapse',
}
const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] || 'in decay'
// The election phases likewise: `nominate` and `vote` are wire values.
// A whole DETAIL LINE, assembled from the parts that are actually present.
//
// The same argument `place()` above makes, one level up: a template that has to
// assemble four optional numbers into a sentence is a template every author gets
// slightly differently, and one whose optionals are absent renders
// "On hand: gold. Charged each period: gold." — which is what the render sweep
// found on a pre-v5 vendor frame. Passing the assembled line means the template
// interpolates ONE variable and the empty case is empty rather than punctuated.
// A wire instant as a person reads it: "2 September 2026, 04:06 UTC".
//
// Core deliberately has no interpolation filters (`interpolate.js` — no ternaries,
// no formatters), so a `datetime` variable renders as whatever string the payload
// holds — and the wire's is an ISO-8601 stamp with seven decimal places, which is
// what a letter from the Merchants' Guild was signing off with. Same argument as
// `place()` and `detailLine()` one line down: the presentation is assembled here,
// at the call site, and arrives as its own value.
//
// **The machine value is never replaced.** `dismissalAt` and `autoPickAt` are
// declared `datetime` and an operator can write `is at most` conditions against
// them (`conditions.js`), so the readable form is an ADDITIONAL variable and the
// ISO one stays exactly as it was.
const readableTime = (iso) => {
if (!iso) return undefined
const at = new Date(iso)
if (Number.isNaN(at.getTime())) return undefined
const day = at.getUTCDate()
const month = MONTHS[at.getUTCMonth()]
const hh = String(at.getUTCHours()).padStart(2, '0')
const mm = String(at.getUTCMinutes()).padStart(2, '0')
return `${day} ${month} ${at.getUTCFullYear()}, ${hh}:${mm} UTC`
}
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const detailLine = (parts) => {
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
}
const PHASE_WORDS = { nominate: 'Nominations are open', vote: 'The ballot is open' }
const phaseLabel = (phase) => PHASE_WORDS[String(phase || '')] || 'The election has moved'
// ── The mappers ────────────────────────────────────────────────────────────
//
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
// dedupeKey? }`. Resolution — account → user id, guild → member ids — happens in
// `dispatch` below, because it needs the database and these must not.
//
// `ownerAccount` is the same field name `shardStreams.js` uses for the same idea,
// so the two mappers can be read side by side.
const decayName = (ev) => ev.name || undefined
const MAPPERS = {
// ── Owned asset at risk ────────────────────────────────────────────────
'house.decay': (ev, tracker, out) => {
const to = String(ev.to || '').toUpperCase()
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial) return
// COLLAPSED is its own trigger; the late stages are the warning. `LikeNew`
// and the early stages are not news — a house being refreshed is the normal
// case and mailing it would make the warning worthless.
if (to === 'COLLAPSED') {
out.push({
triggerId: 'uo.house.collapsed',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseName: decayName(ev),
region: ev.region || undefined,
location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
whereLine: detailLine([['Last recorded at', place(ev)]]),
}),
})
return
}
// **The good outcome** (Phase 11b decision 11). A house refreshed back to
// LikeNew is what `uo.house.idoc_warning`'s 900-second delay exists to give
// the owner time to do, and until this branch the refresh reached the engine
// as silence — so the delay was a late mail rather than a cancellable one.
// Nothing on the wire changed: the decay sweep has always emitted this
// transition, and the early return below was swallowing it.
// **`AGELESS` as well as `LIKENEW`, and the first is the commoner case.**
// ServUO reports `LikeNew` for a house that is still on a decay clock and
// has just been refreshed (`DecayType.ManualRefresh`), and `Ageless` for one
// that is no longer on a clock at all — which is what the owner's newest
// house becomes the moment they log back in, because `DecayType` flips to
// `AutoRefresh` and the getter stops advancing the stage. A returning player
// is the ordinary way a decaying house is rescued, so reading only `LikeNew`
// would miss most rescues. Both mean "out of danger", which is what this
// trigger says.
if (to === 'LIKENEW' || to === 'AGELESS') {
out.push({
triggerId: 'uo.house.refreshed',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
previousStage: ev.from || undefined,
region: ev.region || undefined,
location: place(ev),
houseLabel: houseLabel(decayName(ev), ev.region, serial),
// A TRAILING FRAGMENT, so it leads with its own space and the sentence
// closes cleanly without it. Built only for a stage that has a word —
// "It stood in decay." is the fallback label leaking into prose, and an
// empty fragment reads better than that.
fromLine: trailing(
STAGE_WORDS[String(ev.from || '').toUpperCase()] ? ev.from : null,
(stage) => ` It stood ${stageLabel(stage)}.`,
),
}),
})
return
}
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
out.push({
triggerId: 'uo.house.idoc_warning',
ownerAccount: ev.ownerAcct,
data: defined({
houseSerial: serial,
houseUrl: PATHS.houses,
houseName: decayName(ev),
stage: ev.to,
houseLabel: houseLabel(decayName(ev), ev.region, serial),
stageLabel: stageLabel(ev.to),
whereLine: detailLine([['Recorded at', place(ev)], ['Stage entered', ev.to]]),
previousStage: ev.from || undefined,
region: ev.region || undefined,
location: place(ev),
// **Both optional, and both genuinely absent much of the time.** A v4
// overlay sends no `schedule` at all; a dynamic-decay shard omits
// `estimatedCollapse` at every stage before IDOC because ServUO draws
// each stage's duration at random when the stage is entered. Passing
// `undefined` through is the honest thing — `docs/link/v5.md` is explicit
// that absence means "not knowable", never "not yet read", and computing
// a fallback here would republish exactly the guess the shard refused to.
nextStage: schedule.nextStage || undefined,
estimatedCollapse: schedule.estimatedCollapse || undefined,
lastRefreshed: ev.lastRefreshed || undefined,
}),
})
},
// `house.remove` carries ONLY a serial — the house is gone, so the frame has
// nothing else to say. The owner comes from this module's own registry mirror,
// which is a database read and therefore happens in `dispatch`.
'house.remove': (ev, tracker, out) => {
if (ev.serial == null) return
out.push({
triggerId: 'uo.house.collapsed',
houseSerial: String(ev.serial),
data: { houseSerial: String(ev.serial) },
})
},
'vendor.listing': (ev, tracker, out) => {
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial || !ev.ownerAcct) return
const fees = ev.fees && typeof ev.fees === 'object' ? ev.fees : null
// A pre-v5 overlay sends no `fees`; a commission vendor sends `{exempt:true}`
// and is NEVER dismissed for them. Both mean "nothing to warn about", and
// conflating exempt with a distant date is how a vendor that cannot expire
// ends up in an expiry warning (`docs/link/v5.md`).
if (!fees || fees.exempt === true || !fees.dismissalAt) {
tracker.vendorWarned.delete(serial)
return
}
const at = new Date(fees.dismissalAt)
if (Number.isNaN(at.getTime())) return
const hours = Math.floor((at.getTime() - Date.now()) / 3_600_000)
const inWindow = hours <= VENDOR_WARN_HOURS
const wasWarned = tracker.vendorWarned.get(serial) === true
tracker.vendorWarned.set(serial, inWindow)
// **Only the CROSSING.** The sweep re-emits a shop on any price change, so
// without this a vendor inside the window mails its owner every time somebody
// reprices a longsword. Leaving the window (a deposit) clears the flag above,
// so the next approach warns again — which is the behaviour an owner wants.
if (!inWindow || wasWarned) return
out.push({
triggerId: 'uo.vendor.expiring',
ownerAccount: ev.ownerAcct,
data: defined({
vendorSerial: serial,
marketUrl: PATHS.market,
shopName: ev.shopName || undefined,
shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
dismissalAt: fees.dismissalAt,
// Never negative: a vendor already past its dismissal tick is being
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
hoursRemaining: Math.max(0, hours),
periodsRemaining: Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined,
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
location: place(ev),
ledgerLine: detailLine([
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
['Dismissal', readableTime(fees.dismissalAt)],
['Standing at', place(ev)],
]),
}),
})
},
'vendor.listing.remove': (ev, tracker) => {
if (ev.serial != null) tracker.vendorWarned.delete(String(ev.serial))
},
// ── Passive income ─────────────────────────────────────────────────────
'vendor.sale': (ev, tracker, out) => {
if (!ev.ownerAcct) return
out.push({
triggerId: 'uo.vendor.sale',
ownerAccount: ev.ownerAcct,
data: defined({
vendorSerial: String(ev.vendorSerial ?? ''),
itemName: ev.itemType || 'an item',
// "3 × Iron Ingot" or just "Iron Ingot" — `amount` is optional and a
// sentence reading "sold Iron Ingot" is the hole this closes.
itemLine: Number.isFinite(ev.amount) && ev.amount > 1
? `${ev.amount} × ${ev.itemType || 'an item'}`
: (ev.itemType || 'an item'),
shopLabel: ev.shopName ? `thy shop “${ev.shopName}` : 'thy vendor',
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
price: Number.isFinite(ev.price) ? ev.price : 0,
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
ledgerLine: detailLine([
['Commission withheld', Number.isFinite(ev.commission) ? `${ev.commission} gold` : undefined],
]),
}),
})
},
// ── Personal security ──────────────────────────────────────────────────
//
// **`account.login.result` and NOT `account.login.attempt`.** The attempt fires
// from `EventSink.AccountLogin`, which runs before the auth decision — the
// emitter's own comment says so — and `AccountLoginEventArgs` constructs with
// `Accepted = true`, so a rule on it would have mailed a security alert every
// time the player logged in successfully. That inversion is why protocol 5 adds
// this kind and why the trigger is named `login_failed` rather than `attempt`.
'account.login.result': (ev, tracker, out) => {
if (!ev.acct) return
if (ev.accepted !== false) return
out.push({
triggerId: 'uo.account.login_failed',
ownerAccount: ev.acct,
data: defined({
account: String(ev.acct),
reason: ev.reason || undefined,
ip: ev.ip || undefined,
}),
})
},
// **Resolved BEFORE ingest drops the link mirror**, which is the whole reason
// this file is called from `ingest()` ahead of the state write rather than
// after it. `applyStateChange` removes the `shard_account_links` row for this
// account, so an owner lookup that ran afterwards would find nobody and the one
// person who needs to know their account was unlinked would never be told.
'account.unlinked': (ev, tracker, out) => {
if (!ev.account) return
out.push({
triggerId: 'uo.account.unlinked',
ownerAccount: ev.account,
data: defined({
account: String(ev.account),
characterName: ev.char || undefined,
}),
})
},
// ── Personal milestone ─────────────────────────────────────────────────
'skill.gain': (ev, tracker, out) => {
// The cap, and only the cap. `skill.gain` fires on every tenth of a point;
// `base >= cap` is the milestone and everything else is noise.
if (!Number.isFinite(ev.base) || !Number.isFinite(ev.cap) || ev.base < ev.cap) return
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.skill.capped',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
skill: String(ev.skill || 'a skill'),
cap: ev.cap,
}),
})
},
'quest.complete': (ev, tracker, out) => {
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.quest.complete',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
quest: String(ev.quest || 'a quest'),
}),
})
},
'player.death': (ev, tracker, out) => {
const acct = actorAcct(ev.who)
if (!acct) return
out.push({
triggerId: 'uo.character.death',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.who) || 'your character',
killerName: actorName(ev.killer),
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
}),
})
},
'player.murdered': (ev, tracker, out) => {
const acct = actorAcct(ev.victim)
if (!acct) return
out.push({
triggerId: 'uo.character.murdered',
ownerAccount: acct,
data: defined({
characterName: actorName(ev.victim) || 'your character',
murdererName: actorName(ev.murderer),
slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`),
}),
})
},
// ── Social / civic ─────────────────────────────────────────────────────
//
// `uo.guild.joined` is NOT here: core's `team.member.joined` already fires for
// it on every roster reconcile, because a UO guild is a Team and this module is
// the Team provider. See ENGAGEMENT.md §8.6 for the carve-out.
'guild.leave': (ev, tracker, out) => {
if (ev.id == null) return
out.push({
triggerId: 'uo.guild.left',
guildId: ev.id,
// `who` is a bare SERIAL string here, not an actor object — the mobile has
// already left, so there is nothing for the shard to attribute. The name is
// looked up from the roster mirror in `dispatch`.
memberSerial: ev.who == null ? null : String(ev.who),
data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
})
},
'guild.remove': (ev, tracker, out) => {
if (ev.id == null) return
out.push({
triggerId: 'uo.guild.disbanded',
guildId: ev.id,
// The frame carries ONLY the id, so the name comes from the board mirror in
// `dispatch` — and it has to be read there before `applyStateChange` drops
// the row, the same ordering `account.unlinked` depends on.
data: {},
})
},
'city.update': (ev, tracker, out) => {
const { city } = ev
if (!city) return
// A new governor. Never on FIRST sight (`prev === undefined`), so a reconnect
// snapshot is not read as eight simultaneous elections.
const gov = ev.governor && ev.governor.serial != null ? String(ev.governor.serial) : null
const prevGov = tracker.cityGovernor.get(city)
tracker.cityGovernor.set(city, gov)
if (prevGov !== undefined && gov && gov !== prevGov) {
const governorName = actorName(ev.governor) || 'a new governor'
// **The frame carries no previous holder by name.** The tracker holds the
// outgoing governor's SERIAL and nothing maps a serial to a name here, so
// the succession fragment is empty today and the declaration is optional.
// It is declared rather than omitted so the body does not have to be
// rewritten the day `city.update` gains a `previousGovernor` actor.
const civic = defined({
city: String(city),
governorsUrl: PATHS.governors,
governorName,
previousGovernorName: undefined,
inSuccessionTo: undefined,
})
out.push({ triggerId: 'uo.governor.elected', data: civic })
// **And the letter to the person who won** (Phase 11b, decision 10). Same
// frame, same transition, same never-on-first-sight guard — a different
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
// every actor object it emits, so this needs no protocol change; an
// unlinked governor is simply nobody to write to, which `resolveTarget`
// already treats as an ordinary outcome rather than an error.
const acct = actorAcct(ev.governor)
if (acct) {
out.push({
triggerId: 'uo.governor.appointed',
ownerAccount: acct,
data: { ...civic },
})
}
}
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
// change that arrives without one is not emitted at all rather than emitted
// as a deadline-less call to action — which is what a "vote now" mail with
// nothing to act by would be.
const phase = ev.electionPhase || 'none'
const prevPhase = tracker.cityPhase.get(city)
tracker.cityPhase.set(city, phase)
if (
prevPhase !== undefined
&& phase !== prevPhase
&& (phase === 'nominate' || phase === 'vote')
&& ev.autoPickAt
) {
out.push({
triggerId: 'uo.election.opened',
data: defined({
city: String(city),
governorsUrl: PATHS.governors,
phase,
phaseLabel: phaseLabel(phase),
autoPickAt: ev.autoPickAt,
autoPickWhen: readableTime(ev.autoPickAt),
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
candidateNote: trailing(
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
(n) => (n === 1 ? ' One candidate stands.' : ` ${n} candidates stand.`),
),
}),
})
}
},
// ── Come online now ────────────────────────────────────────────────────
'champ.update': (ev, tracker, out) => {
const serial = ev.serial == null ? null : String(ev.serial)
if (!serial) return
const isActive = ev.active === true
const wasActive = tracker.champActive.get(serial)
tracker.champActive.set(serial, isActive)
const base = defined({
spawnSerial: serial,
champsUrl: PATHS.champs,
spawnName: ev.name || ev.type || 'a champion spawn',
category: ev.category || undefined,
location: place(ev),
atPlace: trailing(place(ev), (p) => ` at ${p}`),
})
if (wasActive !== undefined && isActive && wasActive !== true) {
out.push({ triggerId: 'uo.champ.started', data: base })
}
const bossUp = ev.bossUp === true
const wasBossUp = tracker.champBossUp.get(serial)
tracker.champBossUp.set(serial, bossUp)
if (wasBossUp !== undefined && bossUp && wasBossUp !== true) {
out.push({
triggerId: 'uo.champ.boss_up',
data: defined({ ...base, bossName: ev.boss || undefined }),
})
}
},
// 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))
tracker.champBossUp.delete(String(ev.serial))
},
// **`server.hello` fires on every sidecar reconnect, not only on a shard
// restart** — which is exactly the flapping this trigger must not amplify. The
// tracker's `serverUp` is the guard: a hello while we already believe the shard
// is up is a reconnect and emits nothing. The seeded rule's hard cooldown is the
// second line of defence, for a shard genuinely bouncing.
'server.hello': (ev, tracker, out) => {
const wasUp = tracker.serverUp
tracker.serverUp = true
if (wasUp === true) return
out.push({
triggerId: 'uo.server.up',
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
})
},
'server.shutdown': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
},
'server.crashed': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
},
// ── Leaderboard ────────────────────────────────────────────────────────
//
// `subscribers` only. `top[]` names a mobile SERIAL and `shard_account_links`
// is keyed by game ACCOUNT, so the "you were pushed out" half of §8.6's row is
// carved out rather than resolved for whoever happens to be online.
'points.board': (ev, tracker, out) => {
const system = ev.system
const top = Array.isArray(ev.top) ? ev.top : []
if (!system || !top.length) return
const leader = top.find((e) => e && e.rank === 1) || top[0]
if (!leader || leader.serial == null) return
const serial = String(leader.serial)
const prev = tracker.pointsLeader.get(system)
tracker.pointsLeader.set(system, serial)
if (prev === undefined || prev === serial) return
out.push({
triggerId: 'uo.points.rank_changed',
data: defined({
system: String(system),
systemName: ev.nameString || undefined,
leaderName: leader.name || 'a new leader',
// The board frame carries no previous holder — the tracker holds only a
// SERIAL, and a serial is not a name — so this is the one family whose
// trailing fragment is always empty today. Declared anyway, because the
// alternative is a body that has to be rewritten when the frame gains it.
boardLabel: ev.nameString || String(system),
points: Number.isFinite(leader.points) ? leader.points : undefined,
standingLine: Number.isFinite(leader.points)
? `${leader.name || 'a new leader'} now stands first upon it, with ${leader.points} to their name.`
: `${leader.name || 'a new leader'} now stands first upon it.`,
}),
})
},
// ── Staff-facing ───────────────────────────────────────────────────────
'page.new': (ev, tracker, out) => {
out.push({
triggerId: 'uo.page.new',
data: defined({
pagesUrl: PATHS.ops,
pageType: String(ev.type || 'Other'),
senderName: actorName(ev.sender),
message: ev.message || undefined,
location: place(ev),
}),
})
},
'cheat.fastwalk': (ev, tracker, out) => {
out.push({
triggerId: 'uo.cheat.detected',
data: defined({
characterName: actorName(ev.who) || 'an unnamed character',
account: actorAcct(ev.who),
ip: ev.ip || undefined,
detector: 'fastwalk',
}),
})
},
// ── Operator-facing ────────────────────────────────────────────────────
'audit.set': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
action: 'set',
detail: ev.prop ? `${ev.prop}: ${ev.old ?? '?'}${ev.new ?? '?'}` : undefined,
target: ev.target || undefined,
origin: 'in-game',
}),
})
},
'audit.command': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
action: 'command',
detail: ev.command ? `${ev.command} ${ev.args || ''}`.trim() : undefined,
origin: 'in-game',
}),
})
},
'admin.audit': (ev, tracker, out) => {
out.push({
triggerId: 'uo.audit.staff_action',
data: defined({
staffName: typeof ev.actor === 'string' ? ev.actor : actorName(ev.actor),
action: String(ev.action || 'action'),
detail: ev.reason || undefined,
target: ev.target || undefined,
origin: ev.origin || undefined,
}),
})
},
'economy.supply': (ev, tracker, out) => {
for (const [metric, value, thresholds] of [
['gold', ev.gold, GOLD_THRESHOLDS],
['accounts', ev.accounts, ACCOUNT_THRESHOLDS],
]) {
if (!Number.isFinite(value)) continue
const band = bandOf(value, thresholds)
const prev = tracker.economyBand.get(metric)
tracker.economyBand.set(metric, band)
// First sighting establishes the band and reports nothing. Otherwise a
// sidecar reconnect on a mature shard announces "gold passed a billion"
// about a line it crossed months ago.
if (prev === undefined || prev === band) continue
// The line that was crossed is the HIGHER of the two bands under a rise and
// the one just left under a fall, so both directions name the line the
// reader is thinking about.
const crossed = band > prev ? thresholds[band] : thresholds[prev]
out.push({
triggerId: 'uo.economy.milestone',
data: defined({
economyUrl: PATHS.shard,
metric,
value: Math.round(value),
threshold: crossed,
direction: band > prev ? 'up' : 'down',
}),
})
}
},
'world.save.after': (ev, tracker, out) => {
out.push({
triggerId: 'uo.world.saved',
data: defined({
items: Number.isFinite(ev.items) ? ev.items : undefined,
mobiles: Number.isFinite(ev.mobiles) ? ev.mobiles : undefined,
}),
})
},
}
/**
* Map one shard event to zero or more engagement events. Pure given `tracker`.
*
* Exported so the mapping can be tested without a database, exactly as
* `shardStreams.mapShardEvent` is.
*/
function mapShardEvent(event, tracker = defaultTracker) {
if (!event || typeof event.kind !== 'string') return []
const mapper = MAPPERS[event.kind]
if (!mapper) return []
const out = []
mapper(event, tracker, out)
// **Defence in depth, and the exact counterpart of `shardStreams.js`'s
// public-allowlist filter.** A target naming an id this module does not declare
// cannot be delivered — `emit` would refuse it anyway, throwing in dev and
// logging in prod — so catching it here turns a typo into one warning with the
// id in it rather than an exception on the ingest path.
return out.filter((t) => {
if (TRIGGER_IDS.has(t.triggerId)) return true
log.warn('mapper produced an undeclared trigger id', { kind: event.kind, triggerId: t.triggerId })
return false
})
}
// ── Resolution and dispatch ────────────────────────────────────────────────
/**
* Turn one mapped target into the envelope `ctx.events.emit` takes, or null when
* there is nobody to tell.
*
* This is the half that reaches the database, and it is why the mapping above is
* separate: an owner-keyed target names a GAME ACCOUNT and a members-keyed one
* names a GUILD, and neither is a website user until something asks.
*/
async function resolveTarget(target, deps) {
const { links, state } = deps
const data = { ...target.data }
// `owner` — one account, one user. An unlinked account is nobody to notify,
// which is a normal outcome and not an error: most game accounts on most shards
// have never been linked.
//
// **`userId`, not `user_id`.** The model's `toSafe` camel-cases the row on the
// way out, so reading the column name silently makes EVERY owner-audienced
// trigger resolve to nobody — indistinguishable, from here and from the logs,
// from the ordinary unlinked-account case above. `shardPush.js` is the
// precedent this file follows and it reads `owner.userId`.
if (target.ownerAccount) {
const link = await links.getByAccount(target.ownerAccount)
if (!link || link.userId == null) return null
return { data, ownerUserId: Number(link.userId) }
}
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
// The owner comes from this module's registry mirror — and this read has to
// happen before `applyStateChange` drops the row, which is why ingest calls the
// engagement fan-out ahead of the state write.
if (target.houseSerial) {
const houses = await state.listHouses()
const house = houses.find((h) => String(h.serial) === target.houseSerial)
if (!house || !house.ownerAcct) return null
const link = await links.getByAccount(house.ownerAcct)
if (!link || link.userId == null) return null
if (house.name) data.houseName = house.name
if (house.region) data.region = house.region
return { data, ownerUserId: Number(link.userId) }
}
// `members` — the guild's roster, resolved to website users through
// `shard_account_links` rather than through the roster's mirrored `web_id`.
// The mirror is a copy of what the wire said; the links table is the answer.
if (target.guildId != null) {
const accounts = await state.listGuildMemberAccounts(target.guildId)
const userIds = await links.userIdsForAccounts(accounts)
if (!userIds.length) return null
// Fill in the two names the frames do not carry, from the board mirror.
if (!data.guildName || !data.abbreviation) {
const guilds = await state.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(target.guildId))
if (guild) {
if (!data.guildName) data.guildName = guild.name || `guild ${target.guildId}`
if (guild.abbr && data.abbreviation === undefined) data.abbreviation = guild.abbr
}
}
if (!data.guildName) data.guildName = `guild ${target.guildId}`
// Who left, from the roster mirror — the departing member's row is still
// there, because `guild.leave`'s state write has not run yet.
if (target.memberSerial) {
const members = await state.listGuildMembers(target.guildId)
const gone = members.find((m) => String(m.serial) === target.memberSerial)
if (gone && gone.name) data.memberName = gone.name
// The in-universe body's spine (Phase 11b decision 8). Built HERE and not
// in the mapper because the name comes from the roster mirror, which the
// mapper cannot read — and a herald's notice that names nobody is worse
// than one that says "a member".
if (data.guildName !== undefined) data.memberLabel = data.memberName || 'A member'
}
return { data, recipientUserIds: userIds }
}
// Everything else — `subscribers`, `staff`, `admin` — has no per-event
// audience to resolve. The rule's audience is the whole answer.
return { data }
}
/**
* Fan one shard event out to the engagement engine.
*
* Never throws. Called fire-and-forget from `shardIngest.ingest`, beside the SSE
* broadcast and the push dispatch, and held to the same promise all three make:
* a slow or failing notification path must never delay or fail ingest.
*/
async function fromShardEvent(event, deps = {}) {
const d = {
links: deps.shardLinks || shardLinks,
state: deps.shardState || shardState,
emit: deps.emit || core.events.emit,
tracker: deps.tracker || defaultTracker,
}
for (const target of mapShardEvent(event, d.tracker)) {
try {
const resolved = await resolveTarget(target, d)
// Nobody to tell. Not an error and deliberately not logged at warn: an
// unlinked house owner is the common case on every shard.
if (!resolved) continue
d.emit(target.triggerId, {
data: resolved.data,
...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}),
...(resolved.recipientUserIds ? { recipientUserIds: resolved.recipientUserIds } : {}),
...(target.dedupeKey ? { dedupeKey: target.dedupeKey } : {}),
occurredAt: Number.isFinite(event.t) ? new Date(event.t) : undefined,
})
} catch (err) {
log.warn('engagement target failed', { triggerId: target.triggerId, message: err.message })
}
}
}
module.exports = {
fromShardEvent,
mapShardEvent,
createTracker,
reset,
VENDOR_WARN_HOURS,
GOLD_THRESHOLDS,
ACCOUNT_THRESHOLDS,
}