diff --git a/README.md b/README.md index 0756b68..0f47d6c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/module.json b/module.json index d859dbb..3fad288 100644 --- a/module.json +++ b/module.json @@ -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", diff --git a/server/config/shardAudiences.js b/server/config/shardAudiences.js new file mode 100644 index 0000000..97e6279 --- /dev/null +++ b/server/config/shardAudiences.js @@ -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 } diff --git a/server/config/shardTriggers.js b/server/config/shardTriggers.js new file mode 100644 index 0000000..f4389ae --- /dev/null +++ b/server/config/shardTriggers.js @@ -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, +} diff --git a/server/core.js b/server/core.js index 076a05d..1959383 100644 --- a/server/core.js +++ b/server/core.js @@ -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), diff --git a/server/index.js b/server/index.js index 1421178..e79fef6 100644 --- a/server/index.js +++ b/server/index.js @@ -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, }) } diff --git a/server/model/shardLinks/shardLinks.db.js b/server/model/shardLinks/shardLinks.db.js index f9a00e9..9a304a5 100644 --- a/server/model/shardLinks/shardLinks.db.js +++ b/server/model/shardLinks/shardLinks.db.js @@ -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, +} + diff --git a/server/model/shardLinks/shardLinks.model.js b/server/model/shardLinks/shardLinks.model.js index 3d0f907..6da10f8 100644 --- a/server/model/shardLinks/shardLinks.model.js +++ b/server/model/shardLinks/shardLinks.model.js @@ -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, +} diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js index 9f16061..8250a63 100644 --- a/server/model/shardState/shardState.db.js +++ b/server/model/shardState/shardState.db.js @@ -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, diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js index d4d48c3..2f9d975 100644 --- a/server/model/shardState/shardState.model.js +++ b/server/model/shardState/shardState.model.js @@ -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, diff --git a/server/test/_fakes.js b/server/test/_fakes.js index 6397e16..5753295 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -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 }, } diff --git a/server/test/shardEngagement.test.js b/server/test/shardEngagement.test.js new file mode 100644 index 0000000..ab6a667 --- /dev/null +++ b/server/test/shardEngagement.test.js @@ -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, []) +}) diff --git a/server/utils/shardEngagement.js b/server/utils/shardEngagement.js new file mode 100644 index 0000000..12b7759 --- /dev/null +++ b/server/utils/shardEngagement.js @@ -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, +} diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index 62ad8a1..03762de 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -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) {