Files
Module-uo/server/utils/shardEngagement.js
Claude 849d4b10e8
Some checks failed
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 36s
PR Checks / client-build (pull_request) Successful in 8m17s
fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
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>
2026-09-01 07:12:29 -05:00

988 lines
44 KiB
JavaScript
Raw 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 }),
})
}
},
'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,
}