feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edgemain) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-01 13:59:04 +00:00
14 changed files with 2280 additions and 4 deletions
Showing only changes of commit 419dee3e49 - Show all commits

View File

@@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no
server/router/ routers + controllers, one directory per tier
server/model/ one directory per table family; nothing crosses the boundary
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
server/config/ the push stream catalog
server/config/ the push stream catalog, the engagement triggers and audiences
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
server/scripts/ the three checks: imports, the fragment, the frozen manifest
@@ -112,6 +112,14 @@ client/vite.config.js the library build, the aliases, the not-bundled guard
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
```
**What this module registers with core, beyond its routes.** Seven push streams, one announce leg
(the in-game town crier), a Team provider (a UO guild is a Team), one slash command, and — since
ENGAGEMENT.md Phase 11 — **24 engagement triggers and 3 audiences**. A trigger is a payload contract:
what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever
give it. Core learns none of the vocabulary; it holds ids, labels and ceilings. Declaring a trigger
sends nobody anything — every rule ships disabled. The catalogue, the four rows deliberately absent
and the reasons are in [`docs/modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md) §5.
**The three generated files are committed on purpose.** Two of them are what core reads instead of
looking at this source — it never has it — and the third records which core they were proved against.
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a

View File

@@ -2,7 +2,7 @@
"id": "uo",
"name": "Ultima Online",
"version": "0.4.0",
"coreApi": "^1.3.0",
"coreApi": "^1.8.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

View File

@@ -0,0 +1,99 @@
// ── module-uo's registered audiences ───────────────────────────────────────
//
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
// linked a game account".
//
// **This is a different mechanism from the `members` audience the guild triggers
// use, and the difference is worth stating because the words are the same.** A
// guild event is about the members of THAT guild, which is a different answer for
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
// and the access-checked set travels on the envelope as `recipientUserIds`
// instead (Phase 6, decision 2). What is here answers the same question every
// time it is asked, which is exactly what makes it composable and storable.
//
// **Four rules, all of them from §5.1a:**
//
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
// list and a `resolve` it may call. It has never heard of a guild.
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
// template, a channel or an address and cannot enumerate them. A module still
// cannot send mail, and this must not become the door that lets it — core
// maps ids to addresses on its own side, after preferences, suppression and
// the verification gate.
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
// contains, and the result is still checked against the trigger's own.
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
// than erroring or silently reaching a different set of people.
//
// All three ceiling at `members`, and none higher. `members` is the lattice value
// for "a module-declared list", and it is the honest one here: these sets are not
// "everyone signed in" narrowed down, they are lists this module happens to know.
//
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
// calls, and every one of them fails to the EMPTY set rather than throwing — a
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
// behaviour and much better than a rule that 500s the engine.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const core = require('../core')
const log = core.logger('shard-audiences')
// One wrapper, so every resolver has the same failure behaviour and none of them
// has to remember it. A resolver that throws would fail the whole enqueue for
// every other audience in the same segment.
const safely = (id, fn) => async (params) => {
try {
return await fn(params || {})
} catch (err) {
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
return []
}
}
const AUDIENCES = [
{
// `namespaced()` requires the module's own prefix, so these are declared with
// it rather than relying on core to add one. Audiences have their own id
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
// `uo.guild.members` here does not collide with any trigger id.
id: 'uo.guild.members',
label: 'Members of a guild',
description: 'Everyone with a linked game account on one guild\'s roster.',
params: [{ id: 'guildId', type: 'int', required: true }],
ceiling: 'members',
resolve: safely('uo.guild.members', async ({ guildId }) => {
if (guildId == null) return []
const accounts = await shardState.listGuildMemberAccounts(guildId)
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.governors',
label: 'Town governors',
description: 'Everyone with a linked game account currently holding a city governorship.',
params: [],
ceiling: 'members',
resolve: safely('uo.governors', async () => {
const accounts = await shardState.listGovernorAccounts()
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.linked.accounts',
label: 'Players with a linked game account',
// The set an operator reaches for first, and — more usefully — the one a
// `not` composes against: "everyone who has NOT linked" is the audience for
// the message that asks them to.
description: 'Every website user who has linked at least one game account.',
params: [],
ceiling: 'members',
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
},
]
module.exports = { AUDIENCES }

View File

@@ -0,0 +1,690 @@
// ── module-uo's engagement triggers ────────────────────────────────────────
//
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
// file declares which shard events a player may get a content-free PUSH tickle
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
// may fire on, what a template may interpolate, and the widest audience an
// operator may ever give it.
//
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
// trigger id and a stream id live in the same space and an id has exactly one
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
// preferences and no push toggle, which is correct — `allStreams()` serves the
// stream facet only, so the shipped Android client's catalog is unchanged.
//
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
//
// • `uo.market.item_listed` — a saved SEARCH, not a trigger. Its audience is
// "users whose stored query matches this listing" and no per-user query store
// exists anywhere in the tree.
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
// guild IS a Team and this module is the Team provider, so `teamSync` emits
// on every roster reconcile; a second trigger would be two mails for one join.
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
// • `uo.link.requested` — no addressable recipient by construction (the account
// is not yet linked, which is the point of the event) and a ~5-minute TTL no
// channel can beat.
// • `uo.points.rank_changed`'s personal half — `points.board`'s `top[]` names a
// mobile SERIAL and `shard_account_links` is keyed by ACCOUNT. The board-change
// feed ships at `subscribers`; "you were pushed out" does not.
//
// **Three rules every declaration below obeys, all of them enforced at
// registration** (`registries.js`), so a mistake here is a boot failure rather
// than a defect discovered in someone's mailbox:
//
// 1. **`ceiling` is required and there is no default.** It is the widest
// audience a rule may ever be given (G24), re-checked at save AND at send.
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
// cheat report to the player who was detected, and `staff` is the answer.
// 2. **Every variable carries an `example`.** It is what the template editor
// previews and test-sends with; without one, testing a template needs a live
// game event, which is how template systems ship untested (§4.3 property 3).
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
// rooted" check while being protocol-relative.
//
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
// wire frame into a call; this file is only the contract. Keeping them apart is
// what lets the declarations be read as a catalogue and diffed against §8.6.
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
// changes, not when one is added — an added optional is what `required: false`
// is for, and a stored rule keeps working across it.
const V1 = 1
// ── Owned asset at risk — the flagship family ──────────────────────────────
//
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
// a website user, which is what `ownerUserId` on the envelope carries. A house
// or vendor whose owner never linked an account is nobody to notify, and the
// mapper drops it rather than treating it as an error.
const OWNED_ASSET = [
{
id: 'uo.house.idoc_warning',
label: 'Your house is decaying',
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
kind: 'event',
// The house, not the owner. A player with three decaying houses should hear
// about all three; a cooldown keyed on them would report one and swallow the
// rest. This is the case that makes `subjectKey` worth having at all.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
description: 'The stage it was in before.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
// **Protocol 5, and both are `required: false` on purpose.** A shard still
// running a v4 overlay emits no `schedule` at all, and 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. So the mail has
// to read correctly without them — which is exactly what an optional
// variable and a template that omits an absent one give you.
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
description: 'When the house was last refreshed.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
description: 'Site-relative path to the IDOC page.' },
],
},
{
id: 'uo.house.collapsed',
label: 'Your house collapsed',
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
kind: 'event',
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it had one.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region it stood in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
],
},
{
id: 'uo.vendor.expiring',
label: 'Your vendor is about to be dismissed',
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
description: 'The shop\'s name.' },
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
// **The int an operator narrows with**, because `conditions.js` compares a
// declared variable against a LITERAL and has no relative-time operator:
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
// 24h`. So the hours are computed at emit and the operator writes
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
// on any price change.
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
{ name: 'funds', type: 'int', required: false, example: 8204,
description: 'Gold available to pay the fees.' },
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
description: 'Where the shop stands, already formatted for reading.' },
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
description: 'Site-relative path to the market page.' },
],
},
]
// ── Passive income ─────────────────────────────────────────────────────────
const PASSIVE_INCOME = [
{
id: 'uo.vendor.sale',
label: 'Your vendor sold something',
// **The tier caveat belongs in the operator-facing text, not only in a
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
// only against ServUO 57.4. A shard that declined the tier emits this kind
// never, so a rule on it is silently dormant rather than broken, and the only
// way an operator finds out is if something says so where they are looking.
description:
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
+ 'declined it never emits this event, and a rule on it stays silent.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
description: 'The vendor that made the sale. Also the cooldown subject.' },
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
description: 'What was sold.' },
{ name: 'amount', type: 'int', required: false, example: 1,
description: 'How many.' },
{ name: 'price', type: 'int', required: true, example: 100,
description: 'What it sold for, in gold.' },
{ name: 'commission', type: 'int', required: false, example: 0,
description: 'Commission taken, on a commission vendor.' },
],
},
]
// ── Personal security ──────────────────────────────────────────────────────
const PERSONAL_SECURITY = [
{
id: 'uo.account.login_failed',
label: 'A failed login to your game account',
description: 'Someone tried to log into your game account and was refused.',
kind: 'event',
// The account, so a burst of attempts against one account is one mail and
// attempts against two accounts are two.
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was tried. Also the cooldown subject.' },
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where the attempt came from.' },
],
},
{
id: 'uo.account.unlinked',
label: 'Your game account was unlinked',
description: 'Someone severed the tie between this game account and your website account, from in game.',
kind: 'event',
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was unlinked. Also the cooldown subject.' },
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
description: 'The character who ran the command.' },
],
},
]
// ── Personal milestone ─────────────────────────────────────────────────────
//
// The two death triggers are a killfeed some players want and most do not.
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
// additionally default their channels `off` rather than relying on the rule
// switch alone.
const PERSONAL_MILESTONE = [
{
id: 'uo.skill.capped',
label: 'You capped a skill',
description: 'One of your characters reached the cap in a skill.',
kind: 'event',
subjectKey: 'skill',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who capped it.' },
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
{ name: 'cap', type: 'float', required: true, example: 100,
description: 'The cap that was reached.' },
],
},
{
id: 'uo.quest.complete',
label: 'You completed a quest',
description: 'One of your characters finished a quest.',
kind: 'event',
subjectKey: 'quest',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who finished it.' },
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
description: 'The quest. Also the cooldown subject.' },
],
},
{
id: 'uo.character.death',
label: 'Your character died',
description: 'One of your characters was killed. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who died. Also the cooldown subject.' },
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
description: 'What killed them, when the shard names it.' },
],
},
{
id: 'uo.character.murdered',
label: 'Your character was murdered',
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was murdered. Also the cooldown subject.' },
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
description: 'Who did it, when the shard names them.' },
],
},
]
// ── Social / civic ─────────────────────────────────────────────────────────
//
// The two guild triggers ceiling at `members` and resolve through the recipient
// set the emit carries, not through a saved segment: "the members of THIS guild"
// is a different answer for every firing, which a segment's constant params
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
// precedent it was built for.
const SOCIAL_CIVIC = [
{
id: 'uo.guild.left',
label: 'A member left your guild',
description: 'Someone left a guild you are in.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild. Also the cooldown subject.' },
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
// mobile has already left, so the shard has nothing to attribute. The name
// comes from this module's own roster mirror (`shard_guild_members`), and
// is optional because a member the sweep never saw has no row there.
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
description: 'Who left, when the roster mirror still knows their name.' },
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
description: 'Site-relative path to the guilds page.' },
],
},
{
id: 'uo.guild.disbanded',
label: 'Your guild disbanded',
description: 'A guild you are in was disbanded or removed.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild that is gone. Also the cooldown subject.' },
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
description: 'Its abbreviation.' },
],
},
{
id: 'uo.governor.elected',
label: 'A town elected a governor',
description: 'A city has a new governor.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'The new governor.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
description: 'Site-relative path to the governors page.' },
],
},
{
id: 'uo.election.opened',
label: 'Voting opened in a town',
// **The first trigger whose call to action genuinely expires**, which is why
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
// no deadline is a mail nobody acts on, and one delivered after the deadline
// is worse than none. 11b's template says the date, and the seeded rule uses
// no delay for the same reason.
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'phase', type: 'string', required: true, example: 'vote',
description: 'Which phase opened: nominate or vote.' },
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
description: 'When the game decides for itself — the real deadline.' },
{ name: 'candidates', type: 'int', required: false, example: 3,
description: 'How many candidates stand.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
description: 'Site-relative path to the governors page.' },
],
},
]
// ── Come online now ────────────────────────────────────────────────────────
const COME_ONLINE = [
{
id: 'uo.champ.started',
label: 'A champion spawn started',
description: 'A champion spawn became active.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'What is spawning.' },
{ name: 'category', type: 'string', required: false, example: 'champion',
description: 'champion, mini or sea.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
description: 'Site-relative path to the champions page.' },
],
},
{
id: 'uo.champ.boss_up',
label: 'A champion boss is up',
description: 'A champion spawn reached its boss.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'The spawn.' },
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
description: 'The boss, when the shard names it.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
description: 'Site-relative path to the champions page.' },
],
},
{
id: 'uo.server.up',
label: 'The shard came online',
description: 'The game server started or came back after an outage.',
kind: 'event',
// **No `subjectKey`, and that is the whole point of this pair.** There is one
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
// the shard bounced more than once an hour". Keying it on a boot id would make
// every restart a new subject and every cooldown a no-op, which is precisely
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
// a hard cooldown; this declaration is what makes that cooldown mean anything.
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.server.down',
label: 'The shard went offline',
description: 'The game server shut down or crashed.',
kind: 'event',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true,
description: 'Whether it was a clean shutdown rather than a crash.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
]
// ── Leaderboard ────────────────────────────────────────────────────────────
const LEADERBOARD = [
{
id: 'uo.points.rank_changed',
label: 'A leaderboard top spot changed',
// §8.6 originally described this firing both ways — "you entered a top N" and
// "you were pushed out". The personal half is carved out: `points.board`'s
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
// leaderboard mail that reaches half the board reads as favouritism, so the
// board feed ships and the personal one waits for a serial→account map.
description: 'The top of a leaderboard changed hands.',
kind: 'event',
subjectKey: 'system',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
description: 'The points system. Also the cooldown subject.' },
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
description: 'Its display name, when the shard gives one.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
description: 'Who is first now.' },
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
description: 'Who was first before.' },
{ name: 'points', type: 'int', required: false, example: 29500,
description: 'The new leader\'s points.' },
],
},
]
// ── Staff-facing ───────────────────────────────────────────────────────────
//
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
// trigger out of a player's preferences catalogue AND gates it on write, so this
// family is the production proof of that work rather than new mechanism.
const STAFF_FACING = [
{
id: 'uo.page.new',
label: 'A player opened a help page',
description: 'A player raised a support ticket in game.',
kind: 'event',
subjectKey: 'pageType',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who raised it.' },
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
description: 'What they wrote.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
description: 'Where they are, already formatted for reading.' },
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/shard',
description: 'Site-relative path to the help-page queue.' },
],
},
{
id: 'uo.cheat.detected',
label: 'The cheat detector fired',
description: 'The shard\'s own speed-hack detector flagged a player.',
kind: 'event',
// **`staff`, and never `owner`.** This is the declaration the whole lattice
// was written for: under a flat "fewer people is narrower" ordering a
// `staff` ceiling would also permit `owner`, and the rule an operator would
// then be able to save mails the cheat report to the player who was detected.
subjectKey: 'characterName',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was flagged. Also the cooldown subject.' },
{ name: 'account', type: 'string', required: false, example: 'seed_000',
description: 'Their game account.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where they were connected from.' },
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
description: 'Which detector fired.' },
],
},
]
// ── Operator-facing ────────────────────────────────────────────────────────
//
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
// digest of what moderators did at `staff` would have sent it to the moderators.
// All three are digest-shaped by nature; none should ever be instant, which is a
// property of 11b's seeded rules rather than of these declarations.
const OPERATOR_FACING = [
{
id: 'uo.audit.staff_action',
label: 'A staff member acted in game',
description: 'A staff command, a property change, or a moderation action.',
kind: 'event',
subjectKey: 'staffName',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
{ name: 'action', type: 'string', required: true, example: 'set',
description: 'What kind of action: set, command, ban, kick, mute…' },
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
description: 'The action in one line, already formatted for reading.' },
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who or what it was applied to.' },
{ name: 'origin', type: 'string', required: false, example: 'in-game',
description: 'web or in-game — where the action was issued from.' },
],
},
{
id: 'uo.economy.milestone',
label: 'The economy crossed a threshold',
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
kind: 'event',
subjectKey: 'metric',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'metric', type: 'string', required: true, example: 'gold',
description: 'gold or accounts. Also the cooldown subject.' },
{ name: 'value', type: 'int', required: true, example: 1000000000,
description: 'The value that crossed.' },
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up',
description: 'up or down.' },
{ name: 'economyUrl', type: 'url', required: false, example: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.world.saved',
label: 'The world saved',
description: 'A world save completed, with the item and mobile counts it wrote.',
kind: 'event',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'items', type: 'int', required: false, example: 1482301,
description: 'Items written.' },
{ name: 'mobiles', type: 'int', required: false, example: 41022,
description: 'Mobiles written.' },
],
},
]
const TRIGGERS = [
...OWNED_ASSET,
...PASSIVE_INCOME,
...PERSONAL_SECURITY,
...PERSONAL_MILESTONE,
...SOCIAL_CIVIC,
...COME_ONLINE,
...LEADERBOARD,
...STAFF_FACING,
...OPERATOR_FACING,
]
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
// emit an id this file does not declare, so a typo there is a boot-time-visible
// mistake rather than a dropped event nobody notices.
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
module.exports = {
TRIGGERS,
TRIGGER_IDS,
OWNED_ASSET,
PASSIVE_INCOME,
PERSONAL_SECURITY,
PERSONAL_MILESTONE,
SOCIAL_CIVIC,
COME_ONLINE,
LEADERBOARD,
STAFF_FACING,
OPERATOR_FACING,
}

View File

@@ -93,6 +93,19 @@ module.exports = {
},
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...args) },
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
// event this module DECLARED has happened; the engine decides whether anyone is
// told, on which channel, subject to which rule and preference. `inbox.push`
// writes an in-app item with no rule at all, for the cases that are not events.
//
// Both are fire-and-forget and return undefined by contract — a module calls
// them from inside a game-event handler and there is nothing it could correctly
// do with a storage failure of core's. `inbox.push` additionally does not report
// "the user has this switched off", because a module that could see that would
// be a module that could enumerate people's preferences one write at a time.
events: { emit: (...args) => need().events.emit(...args) },
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args),

View File

@@ -44,6 +44,8 @@ module.exports = function register(ctx, api) {
const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
@@ -88,6 +90,31 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
// are PAYLOAD contracts: what a rule may fire on, what a template may
// interpolate, and — the part that is a security boundary — the widest audience
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
// and core refuses a rule that widens either.
//
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
// registration and the one above are two facets of one space and core enforces
// that an id has exactly one owner across both. None of the ids below reuses a
// stream id: the stream catalog keeps its seven grandfathered names and these
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
// in-app preferences and no push toggle, which is correct — there is nothing to
// push it to, and the shipped Android client's catalog is unchanged.
api.registerEventTriggers(shardTriggers.TRIGGERS)
// Audiences are named sets of PEOPLE an operator composes rules and segments
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
// composition takes the narrowest ceiling it contains, never the widest.
//
// Registration is a claim; nothing resolves until the engine asks, which is
// after `onBoot` — and it must be, because every resolver reads the database
// and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES)
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
@@ -114,5 +141,7 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
})
}

View File

@@ -39,4 +39,56 @@ const remove = (account, userId) =>
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
// A bound on every "resolve a set of people" read below. It mirrors core's own
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
// cannot reach into core's models, and the number this file has to respect is
// "no more ids than core will accept" whatever core calls it.
const MAX_AUDIENCE = 5000
// **Website user ids for a set of game accounts.** The bulk form of
// `getByAccount`, and the one the engagement mapper needs: a guild event's
// audience is its members, and turning a roster into a set of people is one join
// rather than one query per member (Phase 11).
//
// DISTINCT because two characters on one guild roster can share an account, and
// the caller wants people rather than characters.
async function userIdsForAccounts(accounts) {
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
if (!wanted.length) return []
const capped = wanted.slice(0, MAX_AUDIENCE)
const marks = capped.map(() => '?').join(', ')
const rows = await query(
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
capped,
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
// **Every website user with a linked game account** — the `uo.linked.accounts`
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
// one a `not` composes against ("everyone who has NOT linked").
//
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
// never sees an address, a channel or a template, and core maps ids to addresses
// on its own side after preferences, suppression and the verification gate.
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
const rows = await query(
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
[limit],
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
module.exports = {
upsert,
getByAccount,
listByUser,
isOwnedBy,
remove,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
MAX_AUDIENCE,
}

View File

@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
// The bulk resolvers the engagement audiences and the guild mapper need
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
// put here, and a module's audience resolver returning ids and nothing else is
// the contract (§5.1a rule 2).
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
module.exports = {
link,
listForUser,
ownsAccount,
getByAccount,
unlink,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
}

View File

@@ -220,6 +220,26 @@ const listGuildMembers = (guildId) =>
guildId,
])
// **The game accounts on one guild's roster** — the input to
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
// the authoritative map. A mirror that has drifted would mail the wrong person,
// and a mirror that is behind would mail nobody, so the query that decides who
// is told reads the table whose job that is.
const listGuildMemberAccounts = (guildId) =>
query(
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
[guildId],
)
// The accounts of every sitting governor — the `uo.governors` audience.
// `governor_acct` is NULL on a city with no governor and on one whose governor's
// mobile has no account, and both are simply nobody.
const listGovernorAccounts = () =>
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -396,6 +416,8 @@ module.exports = {
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,

View File

@@ -462,6 +462,19 @@ async function listGuildMembers(guildId) {
}))
}
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
// through `shardVisibility`, which strips `acct` for anyone below admin, so
// building an audience out of it would either leak the projection's job into
// this one or silently resolve to nobody depending on who asked. These two go to
// the database for exactly the column they need and pass nothing else on.
const listGuildMemberAccounts = async (guildId) =>
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
const listGovernorAccounts = async () =>
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
@@ -742,6 +755,8 @@ module.exports = {
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,

View File

@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) },
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
// contract — a module gets no delivery answer back, deliberately — so the
// spies return undefined rather than a promise, which is what core does.
events: { emit: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
requireAuth: (req, res, next) => next(),
@@ -97,6 +102,8 @@ function fakeApi() {
legs: [],
teamProvider: null,
slashCommands: [],
triggers: null,
audiences: null,
hooks: {},
}
const called = new Set()
@@ -117,6 +124,11 @@ function fakeApi() {
// takes it: a second call is a module changing its mind halfway through
// register(), which core rejects.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
// the reason above: core stages a registrant's whole batch and applies it as
// one, so a second call is a module changing its mind mid-register().
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,504 @@
// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ─────
//
// Two halves, tested separately for the reason the file splits them: `mapShardEvent`
// is pure given a tracker and needs no database, and `fromShardEvent` is the half
// that resolves an account into a person and therefore does.
//
// What is asserted here is deliberately not "each field is copied". It is the
// three things a rule cannot express and a plain mapping would get wrong —
// transitions, thresholds, and who an event is ABOUT — plus the four places §8.6
// or the protocol docs say the obvious implementation is the wrong one.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const map = (event) => engagement.mapShardEvent(event, tracker)
const ids = (event) => map(event).map((t) => t.triggerId)
const one = (event) => {
const out = map(event)
assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`)
return out[0]
}
// ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
assert.equal(TRIGGERS.length, 24)
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
// assertion is the guard on the carve-outs: adding one back is a decision, and
// a decision should have to edit a test that says so.
for (const carved of [
'uo.market.item_listed', // a saved SEARCH; no per-user query store exists
'uo.guild.joined', // core's team.member.joined already fires for it
'uo.link.requested', // no addressable recipient, and a ~5-minute TTL
]) {
assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`)
}
// Every id is this module's, which is what `namespaced()` enforces at
// registration — asserted here too so the failure names the id rather than
// arriving as a boot error.
for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`)
})
test('every variable carries an example, because a template is previewed with it', () => {
for (const t of TRIGGERS) {
for (const v of t.variables) {
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
assert.ok(v.description, `${t.id}.${v.name} has a description`)
}
// A subjectKey that is not one of the trigger's own variables is refused at
// registration; catching it here names the trigger instead of the boot.
if (t.subjectKey) {
assert.ok(
t.variables.some((v) => v.name === t.subjectKey),
`${t.id} subjectKey "${t.subjectKey}" is one of its variables`,
)
}
}
})
test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => {
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`)
// Not protocol-relative: `//evil.test/x` passes an "is it rooted" check.
assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-relative`)
}
}
})
// The declaration that the whole ceiling lattice exists for.
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
assert.equal(cheat.ceiling, 'staff')
assert.equal(cheat.audience, 'staff')
// The three operator-facing ones sit a rung lower still: `staff` means admin,
// editor AND moderator, so a digest of what moderators did must not ceiling there.
for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) {
assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`)
}
})
// ── Houses ─────────────────────────────────────────────────────────────────
const DECAY = {
kind: 'house.decay',
serial: '0x400142F9',
from: 'Fairly',
to: 'Greatly',
name: 'Millrace',
ownerAcct: 'seed_002',
region: 'Britain',
map: 'Felucca',
x: 1480,
y: 1600,
lastRefreshed: '2026-08-25T17:21:14Z',
}
test('a late decay stage warns the owner; an early one says nothing', () => {
const t = one(DECAY)
assert.equal(t.triggerId, 'uo.house.idoc_warning')
assert.equal(t.ownerAccount, 'seed_002')
assert.equal(t.data.stage, 'Greatly')
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
// A house being refreshed is the normal case. Mailing it would make the
// warning worthless.
assert.deepEqual(ids({ ...DECAY, to: 'LikeNew' }), [])
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
})
test('the v5 schedule rides along when present and is simply absent when not', () => {
const withSchedule = one({
...DECAY,
schedule: {
dynamicDecay: true,
nextStage: '2026-09-01T20:33:15Z',
estimatedCollapse: '2026-09-06T20:33:15Z',
},
})
assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z')
// **A dynamic-decay shard omits `estimatedCollapse` at every stage before
// IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit
// that absence means "not knowable", never "not yet read" — so the mapper must
// pass the absence through rather than computing a fallback, which would
// republish exactly the guess the shard refused to make.
const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } })
assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal('estimatedCollapse' in dynamic.data, false)
const v4 = one(DECAY)
assert.equal('nextStage' in v4.data, false)
assert.equal('estimatedCollapse' in v4.data, false)
})
test('Collapsed is its own trigger, not a louder warning', () => {
const t = one({ ...DECAY, to: 'Collapsed' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, 'seed_002')
})
test('house.remove carries only a serial, so the owner is looked up later', () => {
const t = one({ kind: 'house.remove', serial: '0x400142F9' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, undefined)
assert.equal(t.houseSerial, '0x400142F9')
})
// ── Vendors: the threshold, and the two ways there is nothing to warn about ──
const listing = (fees) => ({
kind: 'vendor.listing',
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerAcct: 'darrow_acct',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
...(fees === undefined ? {} : { fees }),
})
const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString()
const FEES = (h) => ({
exempt: false,
newVendorSystem: true,
chargePerPeriod: 10548,
funds: 8204,
payIntervalSec: 86400,
periodsRemaining: 1,
dismissalAt: inHours(h),
})
test('a vendor entering the warning window fires ONCE, not on every sweep frame', () => {
// `vendor.listing` is re-emitted on any price change, so without the crossing
// check a vendor inside the window mails its owner every time somebody
// reprices a longsword.
const first = one(listing(FEES(20)))
assert.equal(first.triggerId, 'uo.vendor.expiring')
assert.equal(first.ownerAccount, 'darrow_acct')
assert.equal(first.data.hoursRemaining, 19) // floor of 20h minus the tick spent here
assert.deepEqual(ids(listing(FEES(19))), [])
assert.deepEqual(ids(listing(FEES(18))), [])
})
test('a deposit that leaves the window re-arms the warning', () => {
assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring'])
assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window
assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in
})
test('exempt and absent fees are both "nothing to warn about", not "no money"', () => {
// A commission vendor has no PayTimer and is NEVER dismissed for fees.
// Conflating that with a distant date is how a vendor that cannot expire ends
// up in an expiry warning (docs/link/v5.md).
assert.deepEqual(ids(listing({ exempt: true })), [])
// A pre-v5 overlay sends no `fees` block at all.
assert.deepEqual(ids(listing(undefined)), [])
})
test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => {
const t = one(listing(FEES(-3)))
assert.equal(t.data.hoursRemaining, 0)
})
test('an unowned listing is nobody to notify', () => {
const { ownerAcct, ...anonymous } = listing(FEES(10))
assert.deepEqual(ids(anonymous), [])
})
// ── Logins: the inversion protocol 5 exists to fix ─────────────────────────
test('only a FAILED login warns — a successful one produces nothing', () => {
const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' })
assert.equal(failed.triggerId, 'uo.account.login_failed')
assert.equal(failed.data.reason, 'BadPass')
assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), [])
})
test('the pre-decision attempt kind is not mapped at all', () => {
// `account.login.attempt` fires from a sink that runs BEFORE the auth decision
// and whose args default `Accepted = true`, so a rule on it would have mailed a
// security alert on every successful login. That is why v5 added a second kind
// and why this one must stay unmapped.
assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), [])
})
// ── Transitions ────────────────────────────────────────────────────────────
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
assert.deepEqual(ids(champ({ active: true })), [])
assert.deepEqual(ids(champ({ active: true })), []) // still no change
assert.deepEqual(ids(champ({ active: false })), [])
assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started'])
})
test('the boss is its own transition, tracked separately from active', () => {
map(champ({ active: true, bossUp: false }))
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
assert.deepEqual(ids(champ({ active: true, bossUp: true })), [])
})
test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => {
map(champ({ active: false }))
map({ kind: 'champ.remove', serial: '0x40012345' })
assert.deepEqual(ids(champ({ active: true })), [])
})
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
test('a governor change is a transition, and never on first sight', () => {
assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), [])
const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } }))
assert.equal(t.triggerId, 'uo.governor.elected')
assert.equal(t.data.governorName, 'Darrow')
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
})
test('an election opening needs its deadline, or it does not fire', () => {
map(city({ electionPhase: 'none' }))
// **A "vote now" mail with nothing to act by is worse than none**, and
// `autoPickAt` is declared required, so a phase change without one is dropped
// here rather than refused by `emit` later.
assert.deepEqual(ids(city({ electionPhase: 'vote' })), [])
const fresh = engagement.createTracker()
engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh)
const out = engagement.mapShardEvent(
city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }),
fresh,
)
assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened'])
assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z')
})
// ── The shard's own up/down, which is the cooldown table's stress test ─────
test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => {
// `server.hello` is sent on EVERY sidecar reconnect, not only on a shard
// restart, which is exactly the flapping this trigger must not amplify.
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up'])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), [])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), [])
})
test('down fires once per outage, and a crash is told apart from a clean stop', () => {
map({ kind: 'server.hello', shard: 'UOMysticmoon' })
const down = one({ kind: 'server.shutdown' })
assert.equal(down.triggerId, 'uo.server.down')
assert.equal(down.data.clean, true)
assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down
map({ kind: 'server.hello' })
assert.equal(one({ kind: 'server.crashed' }).data.clean, false)
})
// ── Thresholds ─────────────────────────────────────────────────────────────
const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts })
test('an economy milestone fires on a crossing, in both directions, never on first sight', () => {
// A sidecar reconnect on a mature shard must not announce a line it crossed
// months ago.
assert.deepEqual(ids(supply(900_000_000)), [])
const up = one(supply(1_200_000_000))
assert.equal(up.triggerId, 'uo.economy.milestone')
assert.equal(up.data.direction, 'up')
assert.equal(up.data.threshold, 1_000_000_000)
assert.deepEqual(ids(supply(1_300_000_000)), []) // same band
const down = one(supply(800_000_000))
assert.equal(down.data.direction, 'down')
assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through
})
// ── Leaderboards ───────────────────────────────────────────────────────────
const board = (serial, name) => ({
kind: 'points.board',
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }],
})
test('a leaderboard change names the new leader and nobody personally', () => {
assert.deepEqual(ids(board('0x1A2B', 'Darrow')), [])
const t = one(board('0x1A2C', 'Bran'))
assert.equal(t.triggerId, 'uo.points.rank_changed')
assert.equal(t.data.leaderName, 'Bran')
// The personal half is carved out: `top[]` names a mobile SERIAL and links are
// keyed by ACCOUNT, so there is deliberately no owner on this target.
assert.equal(t.ownerAccount, undefined)
assert.deepEqual(ids(board('0x1A2C', 'Bran')), [])
})
// ── Milestones ─────────────────────────────────────────────────────────────
test('only a capped skill is a milestone', () => {
const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), [])
const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 })
assert.equal(t.triggerId, 'uo.skill.capped')
assert.equal(t.ownerAccount, 'seed_000')
// A mobile with no account is nobody's character.
assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), [])
})
test('both deaths address the victim, never the killer', () => {
const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' }
const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } })
assert.equal(death.ownerAccount, 'seed_000')
assert.equal(death.data.killerName, 'an ogre lord')
const murder = one({ kind: 'player.murdered', victim, murderer })
assert.equal(murder.triggerId, 'uo.character.murdered')
assert.equal(murder.ownerAccount, 'seed_000')
assert.equal(murder.data.murdererName, 'Darrow')
})
// ── Guilds ─────────────────────────────────────────────────────────────────
test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => {
const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' })
assert.equal(left.triggerId, 'uo.guild.left')
assert.equal(left.guildId, 1042)
assert.equal(left.memberSerial, '0x77')
assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded')
// Core's `team.member.joined` already fires for this, on every roster
// reconcile, because a UO guild IS a Team and this module is the provider.
// A second trigger would be two mails for one join (§8.6).
assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), [])
})
// ── Staff and operator ─────────────────────────────────────────────────────
test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => {
const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 })
assert.equal(page.triggerId, 'uo.page.new')
assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's
const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' })
assert.equal(cheat.triggerId, 'uo.cheat.detected')
assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected
assert.equal(cheat.data.account, 'seed_000') // but staff are told which account
})
test('the three audit kinds fold into one operator trigger', () => {
assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action'])
assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action'])
const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' })
assert.equal(admin.data.action, 'ban')
assert.equal(admin.data.origin, 'web')
})
test('world.save.after reports what it wrote', () => {
const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 })
assert.equal(t.triggerId, 'uo.world.saved')
assert.equal(t.data.items, 1482301)
// `before` is a boundary, not news.
assert.deepEqual(ids({ kind: 'world.save.before' }), [])
})
// ── The guard ──────────────────────────────────────────────────────────────
test('an unmapped kind and a malformed frame both produce nothing', () => {
assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), [])
assert.deepEqual(ids({ kind: 'region.enter' }), [])
assert.deepEqual(engagement.mapShardEvent(null, tracker), [])
assert.deepEqual(engagement.mapShardEvent({}, tracker), [])
assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), [])
})
// ── Resolution: the half that reaches the database ─────────────────────────
function deps(over = {}) {
const emitted = []
return {
emitted,
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
tracker,
shardLinks: {
getByAccount: async (acct) => (acct === 'seed_002' ? { account: acct, user_id: 7 } : null),
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
...over.shardLinks,
},
shardState: {
listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }],
listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }],
listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }],
listGuildMemberAccounts: async () => ['seed_002'],
...over.shardState,
},
}
}
test('an owner-keyed event resolves the game account to a website user', async () => {
const d = deps()
await engagement.fromShardEvent(DECAY, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning')
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
})
test('an UNLINKED owner is nobody to notify, and that is not an error', async () => {
// The common case on every shard: most game accounts have never been linked.
const d = deps()
await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d)
assert.deepEqual(d.emitted, [])
})
test('house.remove fills the owner and the name in from the registry mirror', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace')
})
test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => {
// §5.1a: "the members of THIS guild" is a different answer every firing, so a
// saved segment cannot express it and the set travels on the envelope
// (Phase 6, decision 2 — the mechanism the Team fan-out was built on).
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d)
assert.equal(d.emitted.length, 1)
assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9])
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
// The two names the frames do not carry come from the mirrors.
assert.equal(d.emitted[0].envelope.data.memberName, 'Bran')
})
test('guild.remove names the guild from the board, because the frame carries only an id', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d)
assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand')
assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH')
})
test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => {
const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } })
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d)
assert.deepEqual(d.emitted, [])
})
test('a subscribers-shaped event needs no resolution at all', async () => {
const d = deps()
engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition
await engagement.fromShardEvent(champ({ active: true }), d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
assert.equal(d.emitted[0].envelope.recipientUserIds, undefined)
})
test('a failing lookup costs that one target and never the ingest feed', async () => {
const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } })
await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d))
assert.deepEqual(d.emitted, [])
})

View File

@@ -0,0 +1,781 @@
// ── 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))
// ── 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),
}),
})
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,
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,
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),
}),
})
},
'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',
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
price: Number.isFinite(ev.price) ? ev.price : 0,
commission: Number.isFinite(ev.commission) ? ev.commission : 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),
}),
})
},
'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),
}),
})
},
// ── 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) {
out.push({
triggerId: 'uo.governor.elected',
data: defined({
city: String(city),
governorName: actorName(ev.governor) || 'a new governor',
previousGovernorName: undefined,
}),
})
}
// 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,
autoPickAt: ev.autoPickAt,
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
}),
})
}
},
// ── 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),
})
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',
points: Number.isFinite(leader.points) ? leader.points : undefined,
}),
})
},
// ── 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
}
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,
}

View File

@@ -20,6 +20,7 @@ const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const { settings: settingsModel } = require('../core')
const broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -62,6 +63,11 @@ const LOGGED_KINDS = new Set([
const state = { bootId: null }
function reset() {
state.bootId = null
// The engagement mapper's transition/threshold tracker is per-process state of
// exactly the same kind as `bootId`, so it is reset by the same call. A test
// that reset one and not the other would see a champion spawn that started in
// the previous test.
shardEngagement.reset()
}
// Should this event be written to the append-only log?
@@ -285,6 +291,7 @@ function resolveDeps(deps) {
settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
engagement: deps.engagement || shardEngagement.fromShardEvent,
log: deps.log || defaultLog,
}
}
@@ -300,6 +307,34 @@ async function ingest(event, deps = {}) {
let stored = false
let logged = false
// **The engagement fan-out runs BEFORE the state write, and that ordering is
// load-bearing rather than incidental** (ENGAGEMENT.md Phase 11). Three of the
// mappings read a row that `applyStateChange` is about to delete or replace:
//
// • `account.unlinked` drops the `shard_account_links` row — the row that
// turns the account into the one person who needs to be told it was
// unlinked. Resolving afterwards finds nobody, every time.
// • `house.remove` drops the house, whose stored `ownerAcct` is the only place
// the owner of a collapsed house is named (the frame carries a serial alone).
// • `guild.leave` / `guild.remove` need the roster and the board mirror to
// name who left and which guild it was.
//
// Awaited, unlike the broadcast and the push tickle below, and this is the one
// place this file waits on a notification path. It has to: the whole point is
// that the read happens first, and a fire-and-forget promise would race the
// DELETE it is trying to precede. `fromShardEvent` never throws and never opens
// a socket — it resolves ids and hands the engine an envelope, which does its
// own work off the caller's stack (`emit` is deliberately not awaited inside).
// Backfilled frames are excluded for the same reason the broadcast is: a
// reconnect replay must not re-notify anyone about events from hours ago.
if (!deps.fromBackfill) {
try {
await d.engagement(event)
} catch (err) {
d.log.warn('engagement fan-out failed', { kind: event.kind, message: err.message })
}
}
try {
await applyStateChange(event, d)
} catch (err) {