Files
Module-uo/server/utils/shardEngagement.js
wtclaude 50a89b48e2
Some checks failed
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 22s
PR Checks / frozen-manifest (pull_request) Failing after -34s
feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
11a declared the triggers; this is the content behind them. Ships through
core's new api.registerEngagementSeeds (MODULE_API 1.9.0): 32 templates and 25
rules, every rule enabled = 0.

THE VOICE (decision 8). The game-powered families read from inside Britannia,
with a per-family in-fiction sender rather than one voice across all sixteen —
Lord Blackthorn's court writes about the crown's business (the seat, the ballot)
and nothing else, because a shard where Blackthorn writes to you personally about
a champion spawn is a shard where the letter about your governorship means
nothing. The Office of Deeds has houses, the Merchants' Guild vendors, a herald
guilds, the town crier champion spawns, a guildmaster skills and quests, the
Chronicler deaths, the keeper of the rolls leaderboards.

WHAT STAYS PLAIN (decision 9). Nine of the 25 point at core's notify.event /
inapp.event and author nothing, and the line is drawn where fiction costs
something real: a failed-login notice written as "a stranger sought entry to thy
account" is indistinguishable in register from the phishing mail it warns about,
and a moderator reading uo.cheat.detected at 2am wants a name, a rule and a
timestamp rather than a scroll. Both account-security triggers, server up/down,
and the five staff/admin-ceiling ones.

THE GOVERNOR'S LETTER (decision 10) — uo.governor.appointed, the 25th trigger.
§8.6 records that uo.points.rank_changed cannot address a person because top[]
names a mobile serial, and the same reasoning was silently assumed to cover the
governor. It does not: city.update's `governor` is written by BridgeJson.Actor(),
which emits serial, name, acct AND webId. The winner is addressable today with no
protocol change. It fires from the same frame, the same transition and the same
never-on-first-sight guard as uo.governor.elected, which stays exactly as
declared — the town's bulletin and the governor's letter are two triggers because
one trigger means one rule means one template, and they are not the same text.
An operator can run either alone.

PRESENTATIONAL FRAGMENTS, because a template has no conditionals by design and an
unset optional interpolates to the empty string. Phase 5a's `forWhom` precedent:
the ternary stays in the mapper and its result arrives as a declared optional.
Two shapes — a LABEL always has a value and carries a sentence's spine
(houseLabel falls back to a seal number); a TRAILING FRAGMENT may be empty and
leads with its own space, so `{{slainBy}}.` closes as "has fallen." either way.
Additive, so no version bump.

A render sweep over all 32 bodies, twice — once with every declared example and
once with required variables only — is what found these. Three defects it caught:
an optional `{{region}}` in a subject line ("A notice concerning thy house at ");
multi-optional ledger lines rendering "On hand:  gold. Charged each period:
gold." on a pre-v5 frame, now assembled in the mapper from the parts actually
present, the same argument place() already makes; and a leading trailing-fragment
opening a body with a stray space.

The labels stay `required: false` deliberately — a missing one must never REFUSE
an emit, since a dropped notification is worse than a cosmetic hole — so nothing
at runtime would notice a mapper that forgot one. engagementSeeds.test.js is what
notices.

524 module tests green; check:imports and check:bundle clean. check:swagger
reports STALE from CRLF alone and regenerates byte-identical — no route changed.

Refs docs ENGAGEMENT.md Phase 11b, decisions 8, 9, 10.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:02:15 -05:00

905 lines
40 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 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.
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
}
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,
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,
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', 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({ 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),
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),
phase,
phaseLabel: phaseLabel(phase),
autoPickAt: 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,
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({ shardName: ev.shard || undefined }),
})
},
'server.shutdown': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { clean: true } })
},
'server.crashed': (ev, tracker, out) => {
if (tracker.serverUp === false) return
tracker.serverUp = false
out.push({ triggerId: 'uo.server.down', data: { 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({
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({
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.
if (target.ownerAccount) {
const link = await links.getByAccount(target.ownerAccount)
if (!link || link.user_id == null) return null
return { data, ownerUserId: Number(link.user_id) }
}
// `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.user_id == null) return null
if (house.name) data.houseName = house.name
if (house.region) data.region = house.region
return { data, ownerUserId: Number(link.user_id) }
}
// `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,
}