feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.
server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.
server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:
* Transitions. champ.update and city.update are full-state upserts, so without
a per-process tracker a sidecar reconnect reads as twenty spawns starting.
A FIRST sighting is never a transition.
* Thresholds. conditions.js compares a declared variable against a LITERAL, so
"within 24 hours of dismissal" is not expressible; and vendor.listing is a
sweep frame re-emitted on any price change, so per-frame would flood. The
crossing is tracked here and `hoursRemaining` is declared so an operator can
still narrow with `is at most`.
* The members audience. "The members of THIS guild" differs every firing, so
it travels on the envelope as recipientUserIds (Phase 6 decision 2).
**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.
Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).
coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.
39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
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/router/ routers + controllers, one directory per tier
|
||||||
server/model/ one directory per table family; nothing crosses the boundary
|
server/model/ one directory per table family; nothing crosses the boundary
|
||||||
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
|
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/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||||
server/scripts/ the three checks: imports, the fragment, the frozen manifest
|
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
|
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
|
**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.
|
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
|
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "uo",
|
"id": "uo",
|
||||||
"name": "Ultima Online",
|
"name": "Ultima Online",
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"coreApi": "^1.3.0",
|
"coreApi": "^1.8.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
99
server/config/shardAudiences.js
Normal file
99
server/config/shardAudiences.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// ── module-uo's registered audiences ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
|
||||||
|
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
|
||||||
|
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
|
||||||
|
// linked a game account".
|
||||||
|
//
|
||||||
|
// **This is a different mechanism from the `members` audience the guild triggers
|
||||||
|
// use, and the difference is worth stating because the words are the same.** A
|
||||||
|
// guild event is about the members of THAT guild, which is a different answer for
|
||||||
|
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
|
||||||
|
// and the access-checked set travels on the envelope as `recipientUserIds`
|
||||||
|
// instead (Phase 6, decision 2). What is here answers the same question every
|
||||||
|
// time it is asked, which is exactly what makes it composable and storable.
|
||||||
|
//
|
||||||
|
// **Four rules, all of them from §5.1a:**
|
||||||
|
//
|
||||||
|
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
|
||||||
|
// list and a `resolve` it may call. It has never heard of a guild.
|
||||||
|
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
|
||||||
|
// template, a channel or an address and cannot enumerate them. A module still
|
||||||
|
// cannot send mail, and this must not become the door that lets it — core
|
||||||
|
// maps ids to addresses on its own side, after preferences, suppression and
|
||||||
|
// the verification gate.
|
||||||
|
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
|
||||||
|
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
|
||||||
|
// contains, and the result is still checked against the trigger's own.
|
||||||
|
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
|
||||||
|
// than erroring or silently reaching a different set of people.
|
||||||
|
//
|
||||||
|
// All three ceiling at `members`, and none higher. `members` is the lattice value
|
||||||
|
// for "a module-declared list", and it is the honest one here: these sets are not
|
||||||
|
// "everyone signed in" narrowed down, they are lists this module happens to know.
|
||||||
|
//
|
||||||
|
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
|
||||||
|
// calls, and every one of them fails to the EMPTY set rather than throwing — a
|
||||||
|
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
|
||||||
|
// behaviour and much better than a rule that 500s the engine.
|
||||||
|
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const core = require('../core')
|
||||||
|
|
||||||
|
const log = core.logger('shard-audiences')
|
||||||
|
|
||||||
|
// One wrapper, so every resolver has the same failure behaviour and none of them
|
||||||
|
// has to remember it. A resolver that throws would fail the whole enqueue for
|
||||||
|
// every other audience in the same segment.
|
||||||
|
const safely = (id, fn) => async (params) => {
|
||||||
|
try {
|
||||||
|
return await fn(params || {})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIENCES = [
|
||||||
|
{
|
||||||
|
// `namespaced()` requires the module's own prefix, so these are declared with
|
||||||
|
// it rather than relying on core to add one. Audiences have their own id
|
||||||
|
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
|
||||||
|
// `uo.guild.members` here does not collide with any trigger id.
|
||||||
|
id: 'uo.guild.members',
|
||||||
|
label: 'Members of a guild',
|
||||||
|
description: 'Everyone with a linked game account on one guild\'s roster.',
|
||||||
|
params: [{ id: 'guildId', type: 'int', required: true }],
|
||||||
|
ceiling: 'members',
|
||||||
|
resolve: safely('uo.guild.members', async ({ guildId }) => {
|
||||||
|
if (guildId == null) return []
|
||||||
|
const accounts = await shardState.listGuildMemberAccounts(guildId)
|
||||||
|
return shardLinks.userIdsForAccounts(accounts)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.governors',
|
||||||
|
label: 'Town governors',
|
||||||
|
description: 'Everyone with a linked game account currently holding a city governorship.',
|
||||||
|
params: [],
|
||||||
|
ceiling: 'members',
|
||||||
|
resolve: safely('uo.governors', async () => {
|
||||||
|
const accounts = await shardState.listGovernorAccounts()
|
||||||
|
return shardLinks.userIdsForAccounts(accounts)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.linked.accounts',
|
||||||
|
label: 'Players with a linked game account',
|
||||||
|
// The set an operator reaches for first, and — more usefully — the one a
|
||||||
|
// `not` composes against: "everyone who has NOT linked" is the audience for
|
||||||
|
// the message that asks them to.
|
||||||
|
description: 'Every website user who has linked at least one game account.',
|
||||||
|
params: [],
|
||||||
|
ceiling: 'members',
|
||||||
|
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = { AUDIENCES }
|
||||||
690
server/config/shardTriggers.js
Normal file
690
server/config/shardTriggers.js
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
// ── module-uo's engagement triggers ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
|
||||||
|
// file declares which shard events a player may get a content-free PUSH tickle
|
||||||
|
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
|
||||||
|
// may fire on, what a template may interpolate, and the widest audience an
|
||||||
|
// operator may ever give it.
|
||||||
|
//
|
||||||
|
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
|
||||||
|
// trigger id and a stream id live in the same space and an id has exactly one
|
||||||
|
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
|
||||||
|
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
|
||||||
|
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
|
||||||
|
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
|
||||||
|
// preferences and no push toggle, which is correct — `allStreams()` serves the
|
||||||
|
// stream facet only, so the shipped Android client's catalog is unchanged.
|
||||||
|
//
|
||||||
|
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
|
||||||
|
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
|
||||||
|
//
|
||||||
|
// • `uo.market.item_listed` — a saved SEARCH, not a trigger. Its audience is
|
||||||
|
// "users whose stored query matches this listing" and no per-user query store
|
||||||
|
// exists anywhere in the tree.
|
||||||
|
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
|
||||||
|
// guild IS a Team and this module is the Team provider, so `teamSync` emits
|
||||||
|
// on every roster reconcile; a second trigger would be two mails for one join.
|
||||||
|
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
|
||||||
|
// • `uo.link.requested` — no addressable recipient by construction (the account
|
||||||
|
// is not yet linked, which is the point of the event) and a ~5-minute TTL no
|
||||||
|
// channel can beat.
|
||||||
|
// • `uo.points.rank_changed`'s personal half — `points.board`'s `top[]` names a
|
||||||
|
// mobile SERIAL and `shard_account_links` is keyed by ACCOUNT. The board-change
|
||||||
|
// feed ships at `subscribers`; "you were pushed out" does not.
|
||||||
|
//
|
||||||
|
// **Three rules every declaration below obeys, all of them enforced at
|
||||||
|
// registration** (`registries.js`), so a mistake here is a boot failure rather
|
||||||
|
// than a defect discovered in someone's mailbox:
|
||||||
|
//
|
||||||
|
// 1. **`ceiling` is required and there is no default.** It is the widest
|
||||||
|
// audience a rule may ever be given (G24), re-checked at save AND at send.
|
||||||
|
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
|
||||||
|
// cheat report to the player who was detected, and `staff` is the answer.
|
||||||
|
// 2. **Every variable carries an `example`.** It is what the template editor
|
||||||
|
// previews and test-sends with; without one, testing a template needs a live
|
||||||
|
// game event, which is how template systems ship untested (§4.3 property 3).
|
||||||
|
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
|
||||||
|
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
|
||||||
|
// rooted" check while being protocol-relative.
|
||||||
|
//
|
||||||
|
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
|
||||||
|
// wire frame into a call; this file is only the contract. Keeping them apart is
|
||||||
|
// what lets the declarations be read as a catalogue and diffed against §8.6.
|
||||||
|
|
||||||
|
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
|
||||||
|
// changes, not when one is added — an added optional is what `required: false`
|
||||||
|
// is for, and a stored rule keeps working across it.
|
||||||
|
const V1 = 1
|
||||||
|
|
||||||
|
// ── Owned asset at risk — the flagship family ──────────────────────────────
|
||||||
|
//
|
||||||
|
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
|
||||||
|
// a website user, which is what `ownerUserId` on the envelope carries. A house
|
||||||
|
// or vendor whose owner never linked an account is nobody to notify, and the
|
||||||
|
// mapper drops it rather than treating it as an error.
|
||||||
|
|
||||||
|
const OWNED_ASSET = [
|
||||||
|
{
|
||||||
|
id: 'uo.house.idoc_warning',
|
||||||
|
label: 'Your house is decaying',
|
||||||
|
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
|
||||||
|
kind: 'event',
|
||||||
|
// The house, not the owner. A player with three decaying houses should hear
|
||||||
|
// about all three; a cooldown keyed on them would report one and swallow the
|
||||||
|
// rest. This is the case that makes `subjectKey` worth having at all.
|
||||||
|
subjectKey: 'houseSerial',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||||||
|
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||||||
|
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||||
|
description: 'The house sign\'s name, when it has one.' },
|
||||||
|
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
|
||||||
|
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
|
||||||
|
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
|
||||||
|
description: 'The stage it was in before.' },
|
||||||
|
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||||||
|
description: 'The named region the house stands in.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||||
|
description: 'Facet and coordinates, already formatted for reading.' },
|
||||||
|
// **Protocol 5, and both are `required: false` on purpose.** A shard still
|
||||||
|
// running a v4 overlay emits no `schedule` at all, and a dynamic-decay shard
|
||||||
|
// omits `estimatedCollapse` at every stage before IDOC because ServUO draws
|
||||||
|
// each stage's duration at random when the stage is entered. So the mail has
|
||||||
|
// to read correctly without them — which is exactly what an optional
|
||||||
|
// variable and a template that omits an absent one give you.
|
||||||
|
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
|
||||||
|
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
|
||||||
|
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
|
||||||
|
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
|
||||||
|
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
|
||||||
|
description: 'When the house was last refreshed.' },
|
||||||
|
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
|
||||||
|
description: 'Site-relative path to the IDOC page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.house.collapsed',
|
||||||
|
label: 'Your house collapsed',
|
||||||
|
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'houseSerial',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||||||
|
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||||||
|
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||||
|
description: 'The house sign\'s name, when it had one.' },
|
||||||
|
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||||||
|
description: 'The named region it stood in.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||||
|
description: 'Facet and coordinates, already formatted for reading.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.vendor.expiring',
|
||||||
|
label: 'Your vendor is about to be dismissed',
|
||||||
|
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'vendorSerial',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
|
||||||
|
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
|
||||||
|
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
|
||||||
|
description: 'The shop\'s name.' },
|
||||||
|
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
|
||||||
|
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
|
||||||
|
// **The int an operator narrows with**, because `conditions.js` compares a
|
||||||
|
// declared variable against a LITERAL and has no relative-time operator:
|
||||||
|
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
|
||||||
|
// 24h`. So the hours are computed at emit and the operator writes
|
||||||
|
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
|
||||||
|
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
|
||||||
|
// on any price change.
|
||||||
|
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
|
||||||
|
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
|
||||||
|
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
|
||||||
|
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
|
||||||
|
{ name: 'funds', type: 'int', required: false, example: 8204,
|
||||||
|
description: 'Gold available to pay the fees.' },
|
||||||
|
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
|
||||||
|
description: 'What each tick deducts.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
|
||||||
|
description: 'Where the shop stands, already formatted for reading.' },
|
||||||
|
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
|
||||||
|
description: 'Site-relative path to the market page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Passive income ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PASSIVE_INCOME = [
|
||||||
|
{
|
||||||
|
id: 'uo.vendor.sale',
|
||||||
|
label: 'Your vendor sold something',
|
||||||
|
// **The tier caveat belongs in the operator-facing text, not only in a
|
||||||
|
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
|
||||||
|
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
|
||||||
|
// only against ServUO 57.4. A shard that declined the tier emits this kind
|
||||||
|
// never, so a rule on it is silently dormant rather than broken, and the only
|
||||||
|
// way an operator finds out is if something says so where they are looking.
|
||||||
|
description:
|
||||||
|
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
|
||||||
|
+ 'declined it never emits this event, and a rule on it stays silent.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'vendorSerial',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
|
||||||
|
description: 'The vendor that made the sale. Also the cooldown subject.' },
|
||||||
|
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
|
||||||
|
description: 'What was sold.' },
|
||||||
|
{ name: 'amount', type: 'int', required: false, example: 1,
|
||||||
|
description: 'How many.' },
|
||||||
|
{ name: 'price', type: 'int', required: true, example: 100,
|
||||||
|
description: 'What it sold for, in gold.' },
|
||||||
|
{ name: 'commission', type: 'int', required: false, example: 0,
|
||||||
|
description: 'Commission taken, on a commission vendor.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Personal security ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PERSONAL_SECURITY = [
|
||||||
|
{
|
||||||
|
id: 'uo.account.login_failed',
|
||||||
|
label: 'A failed login to your game account',
|
||||||
|
description: 'Someone tried to log into your game account and was refused.',
|
||||||
|
kind: 'event',
|
||||||
|
// The account, so a burst of attempts against one account is one mail and
|
||||||
|
// attempts against two accounts are two.
|
||||||
|
subjectKey: 'account',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||||||
|
description: 'The game account that was tried. Also the cooldown subject.' },
|
||||||
|
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
|
||||||
|
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
|
||||||
|
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||||||
|
description: 'Where the attempt came from.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.account.unlinked',
|
||||||
|
label: 'Your game account was unlinked',
|
||||||
|
description: 'Someone severed the tie between this game account and your website account, from in game.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'account',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||||||
|
description: 'The game account that was unlinked. Also the cooldown subject.' },
|
||||||
|
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
|
||||||
|
description: 'The character who ran the command.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Personal milestone ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The two death triggers are a killfeed some players want and most do not.
|
||||||
|
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
|
||||||
|
// additionally default their channels `off` rather than relying on the rule
|
||||||
|
// switch alone.
|
||||||
|
|
||||||
|
const PERSONAL_MILESTONE = [
|
||||||
|
{
|
||||||
|
id: 'uo.skill.capped',
|
||||||
|
label: 'You capped a skill',
|
||||||
|
description: 'One of your characters reached the cap in a skill.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'skill',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||||
|
description: 'The character who capped it.' },
|
||||||
|
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
|
||||||
|
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
|
||||||
|
{ name: 'cap', type: 'float', required: true, example: 100,
|
||||||
|
description: 'The cap that was reached.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.quest.complete',
|
||||||
|
label: 'You completed a quest',
|
||||||
|
description: 'One of your characters finished a quest.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'quest',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||||
|
description: 'The character who finished it.' },
|
||||||
|
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
|
||||||
|
description: 'The quest. Also the cooldown subject.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.character.death',
|
||||||
|
label: 'Your character died',
|
||||||
|
description: 'One of your characters was killed. Opt-in — most players do not want this.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'characterName',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||||
|
description: 'Who died. Also the cooldown subject.' },
|
||||||
|
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
||||||
|
description: 'What killed them, when the shard names it.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.character.murdered',
|
||||||
|
label: 'Your character was murdered',
|
||||||
|
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'characterName',
|
||||||
|
audience: 'owner',
|
||||||
|
ceiling: 'owner',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||||
|
description: 'Who was murdered. Also the cooldown subject.' },
|
||||||
|
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
||||||
|
description: 'Who did it, when the shard names them.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Social / civic ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The two guild triggers ceiling at `members` and resolve through the recipient
|
||||||
|
// set the emit carries, not through a saved segment: "the members of THIS guild"
|
||||||
|
// is a different answer for every firing, which a segment's constant params
|
||||||
|
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
|
||||||
|
// precedent it was built for.
|
||||||
|
|
||||||
|
const SOCIAL_CIVIC = [
|
||||||
|
{
|
||||||
|
id: 'uo.guild.left',
|
||||||
|
label: 'A member left your guild',
|
||||||
|
description: 'Someone left a guild you are in.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'guildName',
|
||||||
|
audience: 'members',
|
||||||
|
ceiling: 'members',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||||||
|
description: 'The guild. Also the cooldown subject.' },
|
||||||
|
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
|
||||||
|
// mobile has already left, so the shard has nothing to attribute. The name
|
||||||
|
// comes from this module's own roster mirror (`shard_guild_members`), and
|
||||||
|
// is optional because a member the sweep never saw has no row there.
|
||||||
|
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
|
||||||
|
description: 'Who left, when the roster mirror still knows their name.' },
|
||||||
|
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
|
||||||
|
description: 'Site-relative path to the guilds page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.guild.disbanded',
|
||||||
|
label: 'Your guild disbanded',
|
||||||
|
description: 'A guild you are in was disbanded or removed.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'guildName',
|
||||||
|
audience: 'members',
|
||||||
|
ceiling: 'members',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||||||
|
description: 'The guild that is gone. Also the cooldown subject.' },
|
||||||
|
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
|
||||||
|
description: 'Its abbreviation.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.governor.elected',
|
||||||
|
label: 'A town elected a governor',
|
||||||
|
description: 'A city has a new governor.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'city',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||||
|
description: 'The city. Also the cooldown subject.' },
|
||||||
|
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||||||
|
description: 'The new governor.' },
|
||||||
|
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||||
|
description: 'Who held the seat before, when there was someone.' },
|
||||||
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||||
|
description: 'Site-relative path to the governors page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.election.opened',
|
||||||
|
label: 'Voting opened in a town',
|
||||||
|
// **The first trigger whose call to action genuinely expires**, which is why
|
||||||
|
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
|
||||||
|
// no deadline is a mail nobody acts on, and one delivered after the deadline
|
||||||
|
// is worse than none. 11b's template says the date, and the seeded rule uses
|
||||||
|
// no delay for the same reason.
|
||||||
|
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'city',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||||
|
description: 'The city. Also the cooldown subject.' },
|
||||||
|
{ name: 'phase', type: 'string', required: true, example: 'vote',
|
||||||
|
description: 'Which phase opened: nominate or vote.' },
|
||||||
|
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
|
||||||
|
description: 'When the game decides for itself — the real deadline.' },
|
||||||
|
{ name: 'candidates', type: 'int', required: false, example: 3,
|
||||||
|
description: 'How many candidates stand.' },
|
||||||
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
||||||
|
description: 'Site-relative path to the governors page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Come online now ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COME_ONLINE = [
|
||||||
|
{
|
||||||
|
id: 'uo.champ.started',
|
||||||
|
label: 'A champion spawn started',
|
||||||
|
description: 'A champion spawn became active.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'spawnSerial',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||||
|
description: 'The spawn controller. Also the cooldown subject.' },
|
||||||
|
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||||||
|
description: 'What is spawning.' },
|
||||||
|
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||||
|
description: 'champion, mini or sea.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||||
|
description: 'Where, already formatted for reading.' },
|
||||||
|
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||||
|
description: 'Site-relative path to the champions page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.champ.boss_up',
|
||||||
|
label: 'A champion boss is up',
|
||||||
|
description: 'A champion spawn reached its boss.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'spawnSerial',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||||
|
description: 'The spawn controller. Also the cooldown subject.' },
|
||||||
|
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||||||
|
description: 'The spawn.' },
|
||||||
|
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
|
||||||
|
description: 'The boss, when the shard names it.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||||
|
description: 'Where, already formatted for reading.' },
|
||||||
|
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
||||||
|
description: 'Site-relative path to the champions page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.server.up',
|
||||||
|
label: 'The shard came online',
|
||||||
|
description: 'The game server started or came back after an outage.',
|
||||||
|
kind: 'event',
|
||||||
|
// **No `subjectKey`, and that is the whole point of this pair.** There is one
|
||||||
|
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
|
||||||
|
// the shard bounced more than once an hour". Keying it on a boot id would make
|
||||||
|
// every restart a new subject and every cooldown a no-op, which is precisely
|
||||||
|
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
|
||||||
|
// a hard cooldown; this declaration is what makes that cooldown mean anything.
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||||||
|
description: 'What the shard calls itself.' },
|
||||||
|
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
|
||||||
|
description: 'Site-relative path to the shard status page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.server.down',
|
||||||
|
label: 'The shard went offline',
|
||||||
|
description: 'The game server shut down or crashed.',
|
||||||
|
kind: 'event',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||||||
|
description: 'What the shard calls itself.' },
|
||||||
|
{ name: 'clean', type: 'boolean', required: false, example: true,
|
||||||
|
description: 'Whether it was a clean shutdown rather than a crash.' },
|
||||||
|
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
|
||||||
|
description: 'Site-relative path to the shard status page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Leaderboard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LEADERBOARD = [
|
||||||
|
{
|
||||||
|
id: 'uo.points.rank_changed',
|
||||||
|
label: 'A leaderboard top spot changed',
|
||||||
|
// §8.6 originally described this firing both ways — "you entered a top N" and
|
||||||
|
// "you were pushed out". The personal half is carved out: `points.board`'s
|
||||||
|
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
|
||||||
|
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
|
||||||
|
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
|
||||||
|
// leaderboard mail that reaches half the board reads as favouritism, so the
|
||||||
|
// board feed ships and the personal one waits for a serial→account map.
|
||||||
|
description: 'The top of a leaderboard changed hands.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'system',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
|
||||||
|
description: 'The points system. Also the cooldown subject.' },
|
||||||
|
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
|
||||||
|
description: 'Its display name, when the shard gives one.' },
|
||||||
|
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
|
||||||
|
description: 'Who is first now.' },
|
||||||
|
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
|
||||||
|
description: 'Who was first before.' },
|
||||||
|
{ name: 'points', type: 'int', required: false, example: 29500,
|
||||||
|
description: 'The new leader\'s points.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Staff-facing ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
|
||||||
|
// trigger out of a player's preferences catalogue AND gates it on write, so this
|
||||||
|
// family is the production proof of that work rather than new mechanism.
|
||||||
|
|
||||||
|
const STAFF_FACING = [
|
||||||
|
{
|
||||||
|
id: 'uo.page.new',
|
||||||
|
label: 'A player opened a help page',
|
||||||
|
description: 'A player raised a support ticket in game.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'pageType',
|
||||||
|
audience: 'staff',
|
||||||
|
ceiling: 'staff',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
|
||||||
|
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
|
||||||
|
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
|
||||||
|
description: 'Who raised it.' },
|
||||||
|
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
|
||||||
|
description: 'What they wrote.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
|
||||||
|
description: 'Where they are, already formatted for reading.' },
|
||||||
|
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/shard',
|
||||||
|
description: 'Site-relative path to the help-page queue.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.cheat.detected',
|
||||||
|
label: 'The cheat detector fired',
|
||||||
|
description: 'The shard\'s own speed-hack detector flagged a player.',
|
||||||
|
kind: 'event',
|
||||||
|
// **`staff`, and never `owner`.** This is the declaration the whole lattice
|
||||||
|
// was written for: under a flat "fewer people is narrower" ordering a
|
||||||
|
// `staff` ceiling would also permit `owner`, and the rule an operator would
|
||||||
|
// then be able to save mails the cheat report to the player who was detected.
|
||||||
|
subjectKey: 'characterName',
|
||||||
|
audience: 'staff',
|
||||||
|
ceiling: 'staff',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||||
|
description: 'Who was flagged. Also the cooldown subject.' },
|
||||||
|
{ name: 'account', type: 'string', required: false, example: 'seed_000',
|
||||||
|
description: 'Their game account.' },
|
||||||
|
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||||||
|
description: 'Where they were connected from.' },
|
||||||
|
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
|
||||||
|
description: 'Which detector fired.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Operator-facing ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
|
||||||
|
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
|
||||||
|
// digest of what moderators did at `staff` would have sent it to the moderators.
|
||||||
|
// All three are digest-shaped by nature; none should ever be instant, which is a
|
||||||
|
// property of 11b's seeded rules rather than of these declarations.
|
||||||
|
|
||||||
|
const OPERATOR_FACING = [
|
||||||
|
{
|
||||||
|
id: 'uo.audit.staff_action',
|
||||||
|
label: 'A staff member acted in game',
|
||||||
|
description: 'A staff command, a property change, or a moderation action.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'staffName',
|
||||||
|
audience: 'admin',
|
||||||
|
ceiling: 'admin',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
|
||||||
|
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
|
||||||
|
{ name: 'action', type: 'string', required: true, example: 'set',
|
||||||
|
description: 'What kind of action: set, command, ban, kick, mute…' },
|
||||||
|
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
|
||||||
|
description: 'The action in one line, already formatted for reading.' },
|
||||||
|
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
|
||||||
|
description: 'Who or what it was applied to.' },
|
||||||
|
{ name: 'origin', type: 'string', required: false, example: 'in-game',
|
||||||
|
description: 'web or in-game — where the action was issued from.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.economy.milestone',
|
||||||
|
label: 'The economy crossed a threshold',
|
||||||
|
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'metric',
|
||||||
|
audience: 'admin',
|
||||||
|
ceiling: 'admin',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'metric', type: 'string', required: true, example: 'gold',
|
||||||
|
description: 'gold or accounts. Also the cooldown subject.' },
|
||||||
|
{ name: 'value', type: 'int', required: true, example: 1000000000,
|
||||||
|
description: 'The value that crossed.' },
|
||||||
|
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
|
||||||
|
description: 'The threshold it crossed.' },
|
||||||
|
{ name: 'direction', type: 'string', required: true, example: 'up',
|
||||||
|
description: 'up or down.' },
|
||||||
|
{ name: 'economyUrl', type: 'url', required: false, example: '/shard',
|
||||||
|
description: 'Site-relative path to the shard status page.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uo.world.saved',
|
||||||
|
label: 'The world saved',
|
||||||
|
description: 'A world save completed, with the item and mobile counts it wrote.',
|
||||||
|
kind: 'event',
|
||||||
|
audience: 'admin',
|
||||||
|
ceiling: 'admin',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'items', type: 'int', required: false, example: 1482301,
|
||||||
|
description: 'Items written.' },
|
||||||
|
{ name: 'mobiles', type: 'int', required: false, example: 41022,
|
||||||
|
description: 'Mobiles written.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const TRIGGERS = [
|
||||||
|
...OWNED_ASSET,
|
||||||
|
...PASSIVE_INCOME,
|
||||||
|
...PERSONAL_SECURITY,
|
||||||
|
...PERSONAL_MILESTONE,
|
||||||
|
...SOCIAL_CIVIC,
|
||||||
|
...COME_ONLINE,
|
||||||
|
...LEADERBOARD,
|
||||||
|
...STAFF_FACING,
|
||||||
|
...OPERATOR_FACING,
|
||||||
|
]
|
||||||
|
|
||||||
|
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
|
||||||
|
// emit an id this file does not declare, so a typo there is a boot-time-visible
|
||||||
|
// mistake rather than a dropped event nobody notices.
|
||||||
|
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TRIGGERS,
|
||||||
|
TRIGGER_IDS,
|
||||||
|
OWNED_ASSET,
|
||||||
|
PASSIVE_INCOME,
|
||||||
|
PERSONAL_SECURITY,
|
||||||
|
PERSONAL_MILESTONE,
|
||||||
|
SOCIAL_CIVIC,
|
||||||
|
COME_ONLINE,
|
||||||
|
LEADERBOARD,
|
||||||
|
STAFF_FACING,
|
||||||
|
OPERATOR_FACING,
|
||||||
|
}
|
||||||
@@ -93,6 +93,19 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||||
push: { publish: (...args) => need().push.publish(...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: {
|
secretBox: {
|
||||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||||
decrypt: (...args) => need().secretBox.decrypt(...args),
|
decrypt: (...args) => need().secretBox.decrypt(...args),
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ module.exports = function register(ctx, api) {
|
|||||||
const usersShardExtension = require('./router/admin/usersShard.router')
|
const usersShardExtension = require('./router/admin/usersShard.router')
|
||||||
|
|
||||||
const shardStreams = require('./config/shardStreams')
|
const shardStreams = require('./config/shardStreams')
|
||||||
|
const shardTriggers = require('./config/shardTriggers')
|
||||||
|
const shardAudiences = require('./config/shardAudiences')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
const guildCommand = require('./commands/guild.command')
|
const guildCommand = require('./commands/guild.command')
|
||||||
@@ -88,6 +90,31 @@ module.exports = function register(ctx, api) {
|
|||||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
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
|
// 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;
|
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
|
||||||
// everything about what a guild IS stays here.
|
// everything about what a guild IS stays here.
|
||||||
@@ -114,5 +141,7 @@ module.exports = function register(ctx, api) {
|
|||||||
version: require('../module.json').version,
|
version: require('../module.json').version,
|
||||||
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||||
streams: shardStreams.STREAMS.length,
|
streams: shardStreams.STREAMS.length,
|
||||||
|
triggers: shardTriggers.TRIGGERS.length,
|
||||||
|
audiences: shardAudiences.AUDIENCES.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,56 @@ const remove = (account, userId) =>
|
|||||||
const removeByAccount = (account) =>
|
const removeByAccount = (account) =>
|
||||||
query('DELETE FROM shard_account_links WHERE account = ?', [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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
|
|||||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||||
const removeByAccount = (account) => db.removeByAccount(account)
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,6 +220,26 @@ const listGuildMembers = (guildId) =>
|
|||||||
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
|
// 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
|
// 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.
|
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||||
@@ -396,6 +416,8 @@ module.exports = {
|
|||||||
removeGuildMember,
|
removeGuildMember,
|
||||||
clearAllGuildMembers,
|
clearAllGuildMembers,
|
||||||
listGuildMembers,
|
listGuildMembers,
|
||||||
|
listGuildMemberAccounts,
|
||||||
|
listGovernorAccounts,
|
||||||
findGuildLedByActor,
|
findGuildLedByActor,
|
||||||
listGuildsLedByAccounts,
|
listGuildsLedByAccounts,
|
||||||
upsertGovernor,
|
upsertGovernor,
|
||||||
|
|||||||
@@ -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) {
|
function shapeGuild(r) {
|
||||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
return payload || {
|
return payload || {
|
||||||
@@ -742,6 +755,8 @@ module.exports = {
|
|||||||
upsertGuildRoster,
|
upsertGuildRoster,
|
||||||
removeGuildMember,
|
removeGuildMember,
|
||||||
listGuildMembers,
|
listGuildMembers,
|
||||||
|
listGuildMemberAccounts,
|
||||||
|
listGovernorAccounts,
|
||||||
replaceGuilds,
|
replaceGuilds,
|
||||||
findGuildForActor,
|
findGuildForActor,
|
||||||
listGuildsLedForAccounts,
|
listGuildsLedForAccounts,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
|
|||||||
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
||||||
auth: { getUserFromRequest: spy(null) },
|
auth: { getUserFromRequest: spy(null) },
|
||||||
push: { publish: spy(Promise.resolve()) },
|
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') },
|
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||||
middleware: {
|
middleware: {
|
||||||
requireAuth: (req, res, next) => next(),
|
requireAuth: (req, res, next) => next(),
|
||||||
@@ -97,6 +102,8 @@ function fakeApi() {
|
|||||||
legs: [],
|
legs: [],
|
||||||
teamProvider: null,
|
teamProvider: null,
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
|
triggers: null,
|
||||||
|
audiences: null,
|
||||||
hooks: {},
|
hooks: {},
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
@@ -117,6 +124,11 @@ function fakeApi() {
|
|||||||
// takes it: a second call is a module changing its mind halfway through
|
// takes it: a second call is a module changing its mind halfway through
|
||||||
// register(), which core rejects.
|
// register(), which core rejects.
|
||||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
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 },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
504
server/test/shardEngagement.test.js
Normal file
504
server/test/shardEngagement.test.js
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ─────
|
||||||
|
//
|
||||||
|
// Two halves, tested separately for the reason the file splits them: `mapShardEvent`
|
||||||
|
// is pure given a tracker and needs no database, and `fromShardEvent` is the half
|
||||||
|
// that resolves an account into a person and therefore does.
|
||||||
|
//
|
||||||
|
// What is asserted here is deliberately not "each field is copied". It is the
|
||||||
|
// three things a rule cannot express and a plain mapping would get wrong —
|
||||||
|
// transitions, thresholds, and who an event is ABOUT — plus the four places §8.6
|
||||||
|
// or the protocol docs say the obvious implementation is the wrong one.
|
||||||
|
|
||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const engagement = require('../utils/shardEngagement')
|
||||||
|
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
||||||
|
|
||||||
|
let tracker
|
||||||
|
beforeEach(() => { tracker = engagement.createTracker() })
|
||||||
|
|
||||||
|
const map = (event) => engagement.mapShardEvent(event, tracker)
|
||||||
|
const ids = (event) => map(event).map((t) => t.triggerId)
|
||||||
|
const one = (event) => {
|
||||||
|
const out = map(event)
|
||||||
|
assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`)
|
||||||
|
return out[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
||||||
|
assert.equal(TRIGGERS.length, 24)
|
||||||
|
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
||||||
|
// assertion is the guard on the carve-outs: adding one back is a decision, and
|
||||||
|
// a decision should have to edit a test that says so.
|
||||||
|
for (const carved of [
|
||||||
|
'uo.market.item_listed', // a saved SEARCH; no per-user query store exists
|
||||||
|
'uo.guild.joined', // core's team.member.joined already fires for it
|
||||||
|
'uo.link.requested', // no addressable recipient, and a ~5-minute TTL
|
||||||
|
]) {
|
||||||
|
assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`)
|
||||||
|
}
|
||||||
|
// Every id is this module's, which is what `namespaced()` enforces at
|
||||||
|
// registration — asserted here too so the failure names the id rather than
|
||||||
|
// arriving as a boot error.
|
||||||
|
for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every variable carries an example, because a template is previewed with it', () => {
|
||||||
|
for (const t of TRIGGERS) {
|
||||||
|
for (const v of t.variables) {
|
||||||
|
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
|
||||||
|
assert.ok(v.description, `${t.id}.${v.name} has a description`)
|
||||||
|
}
|
||||||
|
// A subjectKey that is not one of the trigger's own variables is refused at
|
||||||
|
// registration; catching it here names the trigger instead of the boot.
|
||||||
|
if (t.subjectKey) {
|
||||||
|
assert.ok(
|
||||||
|
t.variables.some((v) => v.name === t.subjectKey),
|
||||||
|
`${t.id} subjectKey "${t.subjectKey}" is one of its variables`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => {
|
||||||
|
for (const t of TRIGGERS) {
|
||||||
|
for (const v of t.variables.filter((x) => x.type === 'url')) {
|
||||||
|
assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`)
|
||||||
|
// Not protocol-relative: `//evil.test/x` passes an "is it rooted" check.
|
||||||
|
assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-relative`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The declaration that the whole ceiling lattice exists for.
|
||||||
|
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
|
||||||
|
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
|
||||||
|
assert.equal(cheat.ceiling, 'staff')
|
||||||
|
assert.equal(cheat.audience, 'staff')
|
||||||
|
// The three operator-facing ones sit a rung lower still: `staff` means admin,
|
||||||
|
// editor AND moderator, so a digest of what moderators did must not ceiling there.
|
||||||
|
for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) {
|
||||||
|
assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Houses ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DECAY = {
|
||||||
|
kind: 'house.decay',
|
||||||
|
serial: '0x400142F9',
|
||||||
|
from: 'Fairly',
|
||||||
|
to: 'Greatly',
|
||||||
|
name: 'Millrace',
|
||||||
|
ownerAcct: 'seed_002',
|
||||||
|
region: 'Britain',
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1480,
|
||||||
|
y: 1600,
|
||||||
|
lastRefreshed: '2026-08-25T17:21:14Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a late decay stage warns the owner; an early one says nothing', () => {
|
||||||
|
const t = one(DECAY)
|
||||||
|
assert.equal(t.triggerId, 'uo.house.idoc_warning')
|
||||||
|
assert.equal(t.ownerAccount, 'seed_002')
|
||||||
|
assert.equal(t.data.stage, 'Greatly')
|
||||||
|
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
|
||||||
|
// A house being refreshed is the normal case. Mailing it would make the
|
||||||
|
// warning worthless.
|
||||||
|
assert.deepEqual(ids({ ...DECAY, to: 'LikeNew' }), [])
|
||||||
|
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the v5 schedule rides along when present and is simply absent when not', () => {
|
||||||
|
const withSchedule = one({
|
||||||
|
...DECAY,
|
||||||
|
schedule: {
|
||||||
|
dynamicDecay: true,
|
||||||
|
nextStage: '2026-09-01T20:33:15Z',
|
||||||
|
estimatedCollapse: '2026-09-06T20:33:15Z',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z')
|
||||||
|
assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z')
|
||||||
|
|
||||||
|
// **A dynamic-decay shard omits `estimatedCollapse` at every stage before
|
||||||
|
// IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit
|
||||||
|
// that absence means "not knowable", never "not yet read" — so the mapper must
|
||||||
|
// pass the absence through rather than computing a fallback, which would
|
||||||
|
// republish exactly the guess the shard refused to make.
|
||||||
|
const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } })
|
||||||
|
assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z')
|
||||||
|
assert.equal('estimatedCollapse' in dynamic.data, false)
|
||||||
|
|
||||||
|
const v4 = one(DECAY)
|
||||||
|
assert.equal('nextStage' in v4.data, false)
|
||||||
|
assert.equal('estimatedCollapse' in v4.data, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Collapsed is its own trigger, not a louder warning', () => {
|
||||||
|
const t = one({ ...DECAY, to: 'Collapsed' })
|
||||||
|
assert.equal(t.triggerId, 'uo.house.collapsed')
|
||||||
|
assert.equal(t.ownerAccount, 'seed_002')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('house.remove carries only a serial, so the owner is looked up later', () => {
|
||||||
|
const t = one({ kind: 'house.remove', serial: '0x400142F9' })
|
||||||
|
assert.equal(t.triggerId, 'uo.house.collapsed')
|
||||||
|
assert.equal(t.ownerAccount, undefined)
|
||||||
|
assert.equal(t.houseSerial, '0x400142F9')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Vendors: the threshold, and the two ways there is nothing to warn about ──
|
||||||
|
|
||||||
|
const listing = (fees) => ({
|
||||||
|
kind: 'vendor.listing',
|
||||||
|
serial: '0x40001234',
|
||||||
|
shopName: "Darrow's Bargains",
|
||||||
|
ownerAcct: 'darrow_acct',
|
||||||
|
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
|
||||||
|
...(fees === undefined ? {} : { fees }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString()
|
||||||
|
|
||||||
|
const FEES = (h) => ({
|
||||||
|
exempt: false,
|
||||||
|
newVendorSystem: true,
|
||||||
|
chargePerPeriod: 10548,
|
||||||
|
funds: 8204,
|
||||||
|
payIntervalSec: 86400,
|
||||||
|
periodsRemaining: 1,
|
||||||
|
dismissalAt: inHours(h),
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a vendor entering the warning window fires ONCE, not on every sweep frame', () => {
|
||||||
|
// `vendor.listing` is re-emitted on any price change, so without the crossing
|
||||||
|
// check a vendor inside the window mails its owner every time somebody
|
||||||
|
// reprices a longsword.
|
||||||
|
const first = one(listing(FEES(20)))
|
||||||
|
assert.equal(first.triggerId, 'uo.vendor.expiring')
|
||||||
|
assert.equal(first.ownerAccount, 'darrow_acct')
|
||||||
|
assert.equal(first.data.hoursRemaining, 19) // floor of 20h minus the tick spent here
|
||||||
|
assert.deepEqual(ids(listing(FEES(19))), [])
|
||||||
|
assert.deepEqual(ids(listing(FEES(18))), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a deposit that leaves the window re-arms the warning', () => {
|
||||||
|
assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring'])
|
||||||
|
assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window
|
||||||
|
assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exempt and absent fees are both "nothing to warn about", not "no money"', () => {
|
||||||
|
// A commission vendor has no PayTimer and is NEVER dismissed for fees.
|
||||||
|
// Conflating that with a distant date is how a vendor that cannot expire ends
|
||||||
|
// up in an expiry warning (docs/link/v5.md).
|
||||||
|
assert.deepEqual(ids(listing({ exempt: true })), [])
|
||||||
|
// A pre-v5 overlay sends no `fees` block at all.
|
||||||
|
assert.deepEqual(ids(listing(undefined)), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => {
|
||||||
|
const t = one(listing(FEES(-3)))
|
||||||
|
assert.equal(t.data.hoursRemaining, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unowned listing is nobody to notify', () => {
|
||||||
|
const { ownerAcct, ...anonymous } = listing(FEES(10))
|
||||||
|
assert.deepEqual(ids(anonymous), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Logins: the inversion protocol 5 exists to fix ─────────────────────────
|
||||||
|
|
||||||
|
test('only a FAILED login warns — a successful one produces nothing', () => {
|
||||||
|
const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' })
|
||||||
|
assert.equal(failed.triggerId, 'uo.account.login_failed')
|
||||||
|
assert.equal(failed.data.reason, 'BadPass')
|
||||||
|
assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the pre-decision attempt kind is not mapped at all', () => {
|
||||||
|
// `account.login.attempt` fires from a sink that runs BEFORE the auth decision
|
||||||
|
// and whose args default `Accepted = true`, so a rule on it would have mailed a
|
||||||
|
// security alert on every successful login. That is why v5 added a second kind
|
||||||
|
// and why this one must stay unmapped.
|
||||||
|
assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Transitions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
|
||||||
|
|
||||||
|
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
|
||||||
|
assert.deepEqual(ids(champ({ active: true })), [])
|
||||||
|
assert.deepEqual(ids(champ({ active: true })), []) // still no change
|
||||||
|
assert.deepEqual(ids(champ({ active: false })), [])
|
||||||
|
assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the boss is its own transition, tracked separately from active', () => {
|
||||||
|
map(champ({ active: true, bossUp: false }))
|
||||||
|
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
|
||||||
|
assert.deepEqual(ids(champ({ active: true, bossUp: true })), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => {
|
||||||
|
map(champ({ active: false }))
|
||||||
|
map({ kind: 'champ.remove', serial: '0x40012345' })
|
||||||
|
assert.deepEqual(ids(champ({ active: true })), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
|
||||||
|
|
||||||
|
test('a governor change is a transition, and never on first sight', () => {
|
||||||
|
assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), [])
|
||||||
|
const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } }))
|
||||||
|
assert.equal(t.triggerId, 'uo.governor.elected')
|
||||||
|
assert.equal(t.data.governorName, 'Darrow')
|
||||||
|
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an election opening needs its deadline, or it does not fire', () => {
|
||||||
|
map(city({ electionPhase: 'none' }))
|
||||||
|
// **A "vote now" mail with nothing to act by is worse than none**, and
|
||||||
|
// `autoPickAt` is declared required, so a phase change without one is dropped
|
||||||
|
// here rather than refused by `emit` later.
|
||||||
|
assert.deepEqual(ids(city({ electionPhase: 'vote' })), [])
|
||||||
|
|
||||||
|
const fresh = engagement.createTracker()
|
||||||
|
engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh)
|
||||||
|
const out = engagement.mapShardEvent(
|
||||||
|
city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }),
|
||||||
|
fresh,
|
||||||
|
)
|
||||||
|
assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened'])
|
||||||
|
assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The shard's own up/down, which is the cooldown table's stress test ─────
|
||||||
|
|
||||||
|
test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => {
|
||||||
|
// `server.hello` is sent on EVERY sidecar reconnect, not only on a shard
|
||||||
|
// restart, which is exactly the flapping this trigger must not amplify.
|
||||||
|
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up'])
|
||||||
|
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), [])
|
||||||
|
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('down fires once per outage, and a crash is told apart from a clean stop', () => {
|
||||||
|
map({ kind: 'server.hello', shard: 'UOMysticmoon' })
|
||||||
|
const down = one({ kind: 'server.shutdown' })
|
||||||
|
assert.equal(down.triggerId, 'uo.server.down')
|
||||||
|
assert.equal(down.data.clean, true)
|
||||||
|
assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down
|
||||||
|
map({ kind: 'server.hello' })
|
||||||
|
assert.equal(one({ kind: 'server.crashed' }).data.clean, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Thresholds ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts })
|
||||||
|
|
||||||
|
test('an economy milestone fires on a crossing, in both directions, never on first sight', () => {
|
||||||
|
// A sidecar reconnect on a mature shard must not announce a line it crossed
|
||||||
|
// months ago.
|
||||||
|
assert.deepEqual(ids(supply(900_000_000)), [])
|
||||||
|
const up = one(supply(1_200_000_000))
|
||||||
|
assert.equal(up.triggerId, 'uo.economy.milestone')
|
||||||
|
assert.equal(up.data.direction, 'up')
|
||||||
|
assert.equal(up.data.threshold, 1_000_000_000)
|
||||||
|
assert.deepEqual(ids(supply(1_300_000_000)), []) // same band
|
||||||
|
const down = one(supply(800_000_000))
|
||||||
|
assert.equal(down.data.direction, 'down')
|
||||||
|
assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Leaderboards ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const board = (serial, name) => ({
|
||||||
|
kind: 'points.board',
|
||||||
|
system: 'QueensLoyalty',
|
||||||
|
nameString: "Queen's Loyalty",
|
||||||
|
top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a leaderboard change names the new leader and nobody personally', () => {
|
||||||
|
assert.deepEqual(ids(board('0x1A2B', 'Darrow')), [])
|
||||||
|
const t = one(board('0x1A2C', 'Bran'))
|
||||||
|
assert.equal(t.triggerId, 'uo.points.rank_changed')
|
||||||
|
assert.equal(t.data.leaderName, 'Bran')
|
||||||
|
// The personal half is carved out: `top[]` names a mobile SERIAL and links are
|
||||||
|
// keyed by ACCOUNT, so there is deliberately no owner on this target.
|
||||||
|
assert.equal(t.ownerAccount, undefined)
|
||||||
|
assert.deepEqual(ids(board('0x1A2C', 'Bran')), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Milestones ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('only a capped skill is a milestone', () => {
|
||||||
|
const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
|
||||||
|
assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), [])
|
||||||
|
const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 })
|
||||||
|
assert.equal(t.triggerId, 'uo.skill.capped')
|
||||||
|
assert.equal(t.ownerAccount, 'seed_000')
|
||||||
|
// A mobile with no account is nobody's character.
|
||||||
|
assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both deaths address the victim, never the killer', () => {
|
||||||
|
const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
|
||||||
|
const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' }
|
||||||
|
const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } })
|
||||||
|
assert.equal(death.ownerAccount, 'seed_000')
|
||||||
|
assert.equal(death.data.killerName, 'an ogre lord')
|
||||||
|
const murder = one({ kind: 'player.murdered', victim, murderer })
|
||||||
|
assert.equal(murder.triggerId, 'uo.character.murdered')
|
||||||
|
assert.equal(murder.ownerAccount, 'seed_000')
|
||||||
|
assert.equal(murder.data.murdererName, 'Darrow')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Guilds ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => {
|
||||||
|
const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' })
|
||||||
|
assert.equal(left.triggerId, 'uo.guild.left')
|
||||||
|
assert.equal(left.guildId, 1042)
|
||||||
|
assert.equal(left.memberSerial, '0x77')
|
||||||
|
|
||||||
|
assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded')
|
||||||
|
|
||||||
|
// Core's `team.member.joined` already fires for this, on every roster
|
||||||
|
// reconcile, because a UO guild IS a Team and this module is the provider.
|
||||||
|
// A second trigger would be two mails for one join (§8.6).
|
||||||
|
assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Staff and operator ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => {
|
||||||
|
const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 })
|
||||||
|
assert.equal(page.triggerId, 'uo.page.new')
|
||||||
|
assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's
|
||||||
|
|
||||||
|
const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' })
|
||||||
|
assert.equal(cheat.triggerId, 'uo.cheat.detected')
|
||||||
|
assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected
|
||||||
|
assert.equal(cheat.data.account, 'seed_000') // but staff are told which account
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the three audit kinds fold into one operator trigger', () => {
|
||||||
|
assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action'])
|
||||||
|
assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action'])
|
||||||
|
const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' })
|
||||||
|
assert.equal(admin.data.action, 'ban')
|
||||||
|
assert.equal(admin.data.origin, 'web')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('world.save.after reports what it wrote', () => {
|
||||||
|
const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 })
|
||||||
|
assert.equal(t.triggerId, 'uo.world.saved')
|
||||||
|
assert.equal(t.data.items, 1482301)
|
||||||
|
// `before` is a boundary, not news.
|
||||||
|
assert.deepEqual(ids({ kind: 'world.save.before' }), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The guard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('an unmapped kind and a malformed frame both produce nothing', () => {
|
||||||
|
assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), [])
|
||||||
|
assert.deepEqual(ids({ kind: 'region.enter' }), [])
|
||||||
|
assert.deepEqual(engagement.mapShardEvent(null, tracker), [])
|
||||||
|
assert.deepEqual(engagement.mapShardEvent({}, tracker), [])
|
||||||
|
assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Resolution: the half that reaches the database ─────────────────────────
|
||||||
|
|
||||||
|
function deps(over = {}) {
|
||||||
|
const emitted = []
|
||||||
|
return {
|
||||||
|
emitted,
|
||||||
|
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
||||||
|
tracker,
|
||||||
|
shardLinks: {
|
||||||
|
getByAccount: async (acct) => (acct === 'seed_002' ? { account: acct, user_id: 7 } : null),
|
||||||
|
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
|
||||||
|
...over.shardLinks,
|
||||||
|
},
|
||||||
|
shardState: {
|
||||||
|
listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }],
|
||||||
|
listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }],
|
||||||
|
listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }],
|
||||||
|
listGuildMemberAccounts: async () => ['seed_002'],
|
||||||
|
...over.shardState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an owner-keyed event resolves the game account to a website user', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await engagement.fromShardEvent(DECAY, d)
|
||||||
|
assert.equal(d.emitted.length, 1)
|
||||||
|
assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning')
|
||||||
|
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an UNLINKED owner is nobody to notify, and that is not an error', async () => {
|
||||||
|
// The common case on every shard: most game accounts have never been linked.
|
||||||
|
const d = deps()
|
||||||
|
await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d)
|
||||||
|
assert.deepEqual(d.emitted, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('house.remove fills the owner and the name in from the registry mirror', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d)
|
||||||
|
assert.equal(d.emitted.length, 1)
|
||||||
|
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
|
||||||
|
assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => {
|
||||||
|
// §5.1a: "the members of THIS guild" is a different answer every firing, so a
|
||||||
|
// saved segment cannot express it and the set travels on the envelope
|
||||||
|
// (Phase 6, decision 2 — the mechanism the Team fan-out was built on).
|
||||||
|
const d = deps()
|
||||||
|
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d)
|
||||||
|
assert.equal(d.emitted.length, 1)
|
||||||
|
assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9])
|
||||||
|
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
|
||||||
|
// The two names the frames do not carry come from the mirrors.
|
||||||
|
assert.equal(d.emitted[0].envelope.data.memberName, 'Bran')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guild.remove names the guild from the board, because the frame carries only an id', async () => {
|
||||||
|
const d = deps()
|
||||||
|
await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d)
|
||||||
|
assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand')
|
||||||
|
assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => {
|
||||||
|
const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } })
|
||||||
|
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d)
|
||||||
|
assert.deepEqual(d.emitted, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a subscribers-shaped event needs no resolution at all', async () => {
|
||||||
|
const d = deps()
|
||||||
|
engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition
|
||||||
|
await engagement.fromShardEvent(champ({ active: true }), d)
|
||||||
|
assert.equal(d.emitted.length, 1)
|
||||||
|
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
|
||||||
|
assert.equal(d.emitted[0].envelope.recipientUserIds, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failing lookup costs that one target and never the ingest feed', async () => {
|
||||||
|
const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } })
|
||||||
|
await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d))
|
||||||
|
assert.deepEqual(d.emitted, [])
|
||||||
|
})
|
||||||
781
server/utils/shardEngagement.js
Normal file
781
server/utils/shardEngagement.js
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
// ── Shard event → engagement trigger ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ENGAGEMENT.md Phase 11. The third fan-out off `shardIngest.ingest`, beside the
|
||||||
|
// SSE broadcast and the push tickle, and the one that produces a PER-PERSON
|
||||||
|
// notification subject to a rule, a preference and a suppression. It is the twin
|
||||||
|
// of `shardPush.js` and reads deliberately like it:
|
||||||
|
//
|
||||||
|
// • `shardStreams.mapShardEvent` turns a frame into push targets;
|
||||||
|
// `mapShardEvent` here turns a frame into engagement events.
|
||||||
|
// • Owner resolution is why neither can be a pure mapper: an owner-keyed target
|
||||||
|
// names a GAME ACCOUNT, and turning that into a website user needs
|
||||||
|
// `shardLinks`. An unlinked account is simply nobody to notify.
|
||||||
|
//
|
||||||
|
// **Nothing here decides who is told.** It says what happened and (for an
|
||||||
|
// owner- or members-shaped event) who it is ABOUT; the engine applies the rules,
|
||||||
|
// the ceiling, the preferences and the suppression list. That split is the
|
||||||
|
// module boundary: a module cannot send mail (§1.2) and this is not the back door.
|
||||||
|
//
|
||||||
|
// **Never throws, never blocks ingest.** `ingest()` calls this fire-and-forget
|
||||||
|
// exactly as it calls the broadcast and the push dispatch, and every mapper below
|
||||||
|
// is wrapped so one bad frame cannot stop the feed. This is the same reason the
|
||||||
|
// C# side's `Emit()` enqueues and returns rather than touching the socket from the
|
||||||
|
// Core thread.
|
||||||
|
//
|
||||||
|
// ── Three things that are NOT a plain field mapping ────────────────────────
|
||||||
|
//
|
||||||
|
// Most of §8.6's rows are "read four fields off the frame and emit". Three are
|
||||||
|
// not, and each is here rather than in a rule because a rule cannot express it:
|
||||||
|
//
|
||||||
|
// 1. **Transitions.** `champ.update` and `city.update` are full-state UPSERTS
|
||||||
|
// re-emitted on any change, not discrete "started"/"elected" events. Without
|
||||||
|
// a per-process transition tracker, a reconnect snapshot is read as twenty
|
||||||
|
// champion spawns starting at once. `shardStreams.js` already solved this for
|
||||||
|
// push and this file uses the same shape — and the same rule that a FIRST
|
||||||
|
// sighting is never a transition.
|
||||||
|
// 2. **Thresholds.** `uo.vendor.expiring` and `uo.economy.milestone` fire when a
|
||||||
|
// value CROSSES a line. `conditions.js` compares a declared variable against
|
||||||
|
// a literal and has no relative-time or previous-value operator, so
|
||||||
|
// "within 24 hours of dismissal" and "gold passed a billion" are not
|
||||||
|
// expressible as conditions — and `vendor.listing` is a sweep frame
|
||||||
|
// re-emitted on every price change, so emitting per frame would flood. The
|
||||||
|
// crossing is tracked here; the operator still narrows with
|
||||||
|
// `hoursRemaining is at most N`.
|
||||||
|
// 3. **Audience resolution for `members`.** A guild event is about the members
|
||||||
|
// of THAT guild, which is a different answer for every firing and therefore
|
||||||
|
// cannot be a saved segment (whose params are constants). The access-checked
|
||||||
|
// set travels on the envelope as `recipientUserIds` — Phase 6's decision 2,
|
||||||
|
// and the mechanism the Team fan-out was built on.
|
||||||
|
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const { TRIGGER_IDS } = require('../config/shardTriggers')
|
||||||
|
const core = require('../core')
|
||||||
|
|
||||||
|
const log = core.logger('shard-engagement')
|
||||||
|
|
||||||
|
// ── Thresholds ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// When a vendor becomes "expiring". Hours rather than pay periods, because a pay
|
||||||
|
// period is a real day under the new vendor system and a UO day (~2 real hours)
|
||||||
|
// under the old one — the exact factor-of-twelve trap `docs/link/v5.md` records,
|
||||||
|
// and the reason the wire carries `dismissalAt` as an instant.
|
||||||
|
//
|
||||||
|
// 48 hours is one full real day of warning even on a shard whose owner logs in
|
||||||
|
// daily, and it is the OUTER edge: the mapper fires once on the way in, and the
|
||||||
|
// operator narrows further with `hoursRemaining is at most 24` if they want less.
|
||||||
|
const VENDOR_WARN_HOURS = 48
|
||||||
|
|
||||||
|
// Gold-supply reporting lines, ascending. Crossing one in either direction is one
|
||||||
|
// `uo.economy.milestone`. They are the module's rather than the operator's for
|
||||||
|
// now: an admin-configurable ladder is a settings surface, and this phase's job
|
||||||
|
// is the trigger. An operator who wants a different line writes a rule condition
|
||||||
|
// on `value`.
|
||||||
|
const GOLD_THRESHOLDS = [
|
||||||
|
100_000_000, 250_000_000, 500_000_000, 1_000_000_000,
|
||||||
|
2_500_000_000, 5_000_000_000, 10_000_000_000,
|
||||||
|
]
|
||||||
|
|
||||||
|
// The same, for account count.
|
||||||
|
const ACCOUNT_THRESHOLDS = [100, 250, 500, 1000, 2500, 5000, 10_000]
|
||||||
|
|
||||||
|
// Which line a value sits above, as an index. -1 means "below the first".
|
||||||
|
const bandOf = (value, thresholds) => {
|
||||||
|
let band = -1
|
||||||
|
for (let i = 0; i < thresholds.length; i += 1) if (value >= thresholds[i]) band = i
|
||||||
|
return band
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The transition tracker ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-process state for the upsert kinds and the threshold kinds.
|
||||||
|
*
|
||||||
|
* Injectable so a test gets a fresh one; a module-level default backs the live
|
||||||
|
* dispatcher. It is deliberately NOT persisted: its whole job is to say "has
|
||||||
|
* this process seen a previous value", and a value restored from a database
|
||||||
|
* would make the first frame after a restart a transition against state the
|
||||||
|
* shard may have left behind hours ago.
|
||||||
|
*/
|
||||||
|
function createTracker() {
|
||||||
|
return {
|
||||||
|
champActive: new Map(), // spawn serial → boolean
|
||||||
|
champBossUp: new Map(), // spawn serial → boolean
|
||||||
|
cityGovernor: new Map(), // city → governor serial or null
|
||||||
|
cityPhase: new Map(), // city → electionPhase
|
||||||
|
vendorWarned: new Map(), // vendor serial → boolean (already inside the window)
|
||||||
|
pointsLeader: new Map(), // points system → leader serial
|
||||||
|
economyBand: new Map(), // metric → band index
|
||||||
|
serverUp: null, // boolean or null (never seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const defaultTracker = createTracker()
|
||||||
|
|
||||||
|
/** Reset the module-level tracker. For tests and for `shardIngest.reset()`. */
|
||||||
|
function reset() {
|
||||||
|
const fresh = createTracker()
|
||||||
|
for (const key of Object.keys(fresh)) defaultTracker[key] = fresh[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small shared shapes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// "Felucca 1480, 1600" — one string rather than four variables, because a
|
||||||
|
// template that has to assemble coordinates is a template every author gets
|
||||||
|
// slightly differently. Returns undefined when there is nothing to format, so it
|
||||||
|
// drops out of an optional variable rather than rendering "undefined , ".
|
||||||
|
function place(ev) {
|
||||||
|
const map = ev.map || (ev.location && ev.location.map)
|
||||||
|
const x = ev.x ?? (ev.location && ev.location.x)
|
||||||
|
const y = ev.y ?? (ev.location && ev.location.y)
|
||||||
|
if (!map && x == null) return undefined
|
||||||
|
const coords = x == null || y == null ? '' : ` ${x}, ${y}`
|
||||||
|
const region = ev.region || (ev.location && ev.location.region)
|
||||||
|
const suffix = region ? ` (${region})` : ''
|
||||||
|
return `${map || ''}${coords}${suffix}`.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// An actor object's display name, whichever of the shard's shapes it arrives in.
|
||||||
|
const actorName = (actor) => (actor && typeof actor === 'object' ? actor.name : undefined) || undefined
|
||||||
|
const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct : undefined) || undefined
|
||||||
|
|
||||||
|
// Drop the undefined values before they reach `emit`. A declared OPTIONAL
|
||||||
|
// variable that arrives as `undefined` is dropped by `validatePayload` anyway,
|
||||||
|
// but building the object without them keeps the emit log's `variables` list
|
||||||
|
// honest about what the frame actually carried.
|
||||||
|
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
|
||||||
|
|
||||||
|
// ── The mappers ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
|
||||||
|
// dedupeKey? }`. Resolution — account → user id, guild → member ids — happens in
|
||||||
|
// `dispatch` below, because it needs the database and these must not.
|
||||||
|
//
|
||||||
|
// `ownerAccount` is the same field name `shardStreams.js` uses for the same idea,
|
||||||
|
// so the two mappers can be read side by side.
|
||||||
|
|
||||||
|
const decayName = (ev) => ev.name || undefined
|
||||||
|
|
||||||
|
const MAPPERS = {
|
||||||
|
// ── Owned asset at risk ────────────────────────────────────────────────
|
||||||
|
'house.decay': (ev, tracker, out) => {
|
||||||
|
const to = String(ev.to || '').toUpperCase()
|
||||||
|
const serial = ev.serial == null ? null : String(ev.serial)
|
||||||
|
if (!serial) return
|
||||||
|
// COLLAPSED is its own trigger; the late stages are the warning. `LikeNew`
|
||||||
|
// and the early stages are not news — a house being refreshed is the normal
|
||||||
|
// case and mailing it would make the warning worthless.
|
||||||
|
if (to === 'COLLAPSED') {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.house.collapsed',
|
||||||
|
ownerAccount: ev.ownerAcct,
|
||||||
|
data: defined({
|
||||||
|
houseSerial: serial,
|
||||||
|
houseName: decayName(ev),
|
||||||
|
region: ev.region || undefined,
|
||||||
|
location: place(ev),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
|
||||||
|
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.house.idoc_warning',
|
||||||
|
ownerAccount: ev.ownerAcct,
|
||||||
|
data: defined({
|
||||||
|
houseSerial: serial,
|
||||||
|
houseName: decayName(ev),
|
||||||
|
stage: ev.to,
|
||||||
|
previousStage: ev.from || undefined,
|
||||||
|
region: ev.region || undefined,
|
||||||
|
location: place(ev),
|
||||||
|
// **Both optional, and both genuinely absent much of the time.** A v4
|
||||||
|
// overlay sends no `schedule` at all; a dynamic-decay shard omits
|
||||||
|
// `estimatedCollapse` at every stage before IDOC because ServUO draws
|
||||||
|
// each stage's duration at random when the stage is entered. Passing
|
||||||
|
// `undefined` through is the honest thing — `docs/link/v5.md` is explicit
|
||||||
|
// that absence means "not knowable", never "not yet read", and computing
|
||||||
|
// a fallback here would republish exactly the guess the shard refused to.
|
||||||
|
nextStage: schedule.nextStage || undefined,
|
||||||
|
estimatedCollapse: schedule.estimatedCollapse || undefined,
|
||||||
|
lastRefreshed: ev.lastRefreshed || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// `house.remove` carries ONLY a serial — the house is gone, so the frame has
|
||||||
|
// nothing else to say. The owner comes from this module's own registry mirror,
|
||||||
|
// which is a database read and therefore happens in `dispatch`.
|
||||||
|
'house.remove': (ev, tracker, out) => {
|
||||||
|
if (ev.serial == null) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.house.collapsed',
|
||||||
|
houseSerial: String(ev.serial),
|
||||||
|
data: { houseSerial: String(ev.serial) },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'vendor.listing': (ev, tracker, out) => {
|
||||||
|
const serial = ev.serial == null ? null : String(ev.serial)
|
||||||
|
if (!serial || !ev.ownerAcct) return
|
||||||
|
const fees = ev.fees && typeof ev.fees === 'object' ? ev.fees : null
|
||||||
|
// A pre-v5 overlay sends no `fees`; a commission vendor sends `{exempt:true}`
|
||||||
|
// and is NEVER dismissed for them. Both mean "nothing to warn about", and
|
||||||
|
// conflating exempt with a distant date is how a vendor that cannot expire
|
||||||
|
// ends up in an expiry warning (`docs/link/v5.md`).
|
||||||
|
if (!fees || fees.exempt === true || !fees.dismissalAt) {
|
||||||
|
tracker.vendorWarned.delete(serial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const at = new Date(fees.dismissalAt)
|
||||||
|
if (Number.isNaN(at.getTime())) return
|
||||||
|
const hours = Math.floor((at.getTime() - Date.now()) / 3_600_000)
|
||||||
|
const inWindow = hours <= VENDOR_WARN_HOURS
|
||||||
|
const wasWarned = tracker.vendorWarned.get(serial) === true
|
||||||
|
tracker.vendorWarned.set(serial, inWindow)
|
||||||
|
// **Only the CROSSING.** The sweep re-emits a shop on any price change, so
|
||||||
|
// without this a vendor inside the window mails its owner every time somebody
|
||||||
|
// reprices a longsword. Leaving the window (a deposit) clears the flag above,
|
||||||
|
// so the next approach warns again — which is the behaviour an owner wants.
|
||||||
|
if (!inWindow || wasWarned) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.vendor.expiring',
|
||||||
|
ownerAccount: ev.ownerAcct,
|
||||||
|
data: defined({
|
||||||
|
vendorSerial: serial,
|
||||||
|
shopName: ev.shopName || undefined,
|
||||||
|
dismissalAt: fees.dismissalAt,
|
||||||
|
// Never negative: a vendor already past its dismissal tick is being
|
||||||
|
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
|
||||||
|
hoursRemaining: Math.max(0, hours),
|
||||||
|
periodsRemaining: Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined,
|
||||||
|
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
|
||||||
|
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
|
||||||
|
location: place(ev),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'vendor.listing.remove': (ev, tracker) => {
|
||||||
|
if (ev.serial != null) tracker.vendorWarned.delete(String(ev.serial))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Passive income ─────────────────────────────────────────────────────
|
||||||
|
'vendor.sale': (ev, tracker, out) => {
|
||||||
|
if (!ev.ownerAcct) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.vendor.sale',
|
||||||
|
ownerAccount: ev.ownerAcct,
|
||||||
|
data: defined({
|
||||||
|
vendorSerial: String(ev.vendorSerial ?? ''),
|
||||||
|
itemName: ev.itemType || 'an item',
|
||||||
|
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
|
||||||
|
price: Number.isFinite(ev.price) ? ev.price : 0,
|
||||||
|
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Personal security ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// **`account.login.result` and NOT `account.login.attempt`.** The attempt fires
|
||||||
|
// from `EventSink.AccountLogin`, which runs before the auth decision — the
|
||||||
|
// emitter's own comment says so — and `AccountLoginEventArgs` constructs with
|
||||||
|
// `Accepted = true`, so a rule on it would have mailed a security alert every
|
||||||
|
// time the player logged in successfully. That inversion is why protocol 5 adds
|
||||||
|
// this kind and why the trigger is named `login_failed` rather than `attempt`.
|
||||||
|
'account.login.result': (ev, tracker, out) => {
|
||||||
|
if (!ev.acct) return
|
||||||
|
if (ev.accepted !== false) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.account.login_failed',
|
||||||
|
ownerAccount: ev.acct,
|
||||||
|
data: defined({
|
||||||
|
account: String(ev.acct),
|
||||||
|
reason: ev.reason || undefined,
|
||||||
|
ip: ev.ip || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// **Resolved BEFORE ingest drops the link mirror**, which is the whole reason
|
||||||
|
// this file is called from `ingest()` ahead of the state write rather than
|
||||||
|
// after it. `applyStateChange` removes the `shard_account_links` row for this
|
||||||
|
// account, so an owner lookup that ran afterwards would find nobody and the one
|
||||||
|
// person who needs to know their account was unlinked would never be told.
|
||||||
|
'account.unlinked': (ev, tracker, out) => {
|
||||||
|
if (!ev.account) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.account.unlinked',
|
||||||
|
ownerAccount: ev.account,
|
||||||
|
data: defined({
|
||||||
|
account: String(ev.account),
|
||||||
|
characterName: ev.char || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Personal milestone ─────────────────────────────────────────────────
|
||||||
|
'skill.gain': (ev, tracker, out) => {
|
||||||
|
// The cap, and only the cap. `skill.gain` fires on every tenth of a point;
|
||||||
|
// `base >= cap` is the milestone and everything else is noise.
|
||||||
|
if (!Number.isFinite(ev.base) || !Number.isFinite(ev.cap) || ev.base < ev.cap) return
|
||||||
|
const acct = actorAcct(ev.who)
|
||||||
|
if (!acct) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.skill.capped',
|
||||||
|
ownerAccount: acct,
|
||||||
|
data: defined({
|
||||||
|
characterName: actorName(ev.who) || 'your character',
|
||||||
|
skill: String(ev.skill || 'a skill'),
|
||||||
|
cap: ev.cap,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'quest.complete': (ev, tracker, out) => {
|
||||||
|
const acct = actorAcct(ev.who)
|
||||||
|
if (!acct) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.quest.complete',
|
||||||
|
ownerAccount: acct,
|
||||||
|
data: defined({
|
||||||
|
characterName: actorName(ev.who) || 'your character',
|
||||||
|
quest: String(ev.quest || 'a quest'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'player.death': (ev, tracker, out) => {
|
||||||
|
const acct = actorAcct(ev.who)
|
||||||
|
if (!acct) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.character.death',
|
||||||
|
ownerAccount: acct,
|
||||||
|
data: defined({
|
||||||
|
characterName: actorName(ev.who) || 'your character',
|
||||||
|
killerName: actorName(ev.killer),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'player.murdered': (ev, tracker, out) => {
|
||||||
|
const acct = actorAcct(ev.victim)
|
||||||
|
if (!acct) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.character.murdered',
|
||||||
|
ownerAccount: acct,
|
||||||
|
data: defined({
|
||||||
|
characterName: actorName(ev.victim) || 'your character',
|
||||||
|
murdererName: actorName(ev.murderer),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Social / civic ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `uo.guild.joined` is NOT here: core's `team.member.joined` already fires for
|
||||||
|
// it on every roster reconcile, because a UO guild is a Team and this module is
|
||||||
|
// the Team provider. See ENGAGEMENT.md §8.6 for the carve-out.
|
||||||
|
'guild.leave': (ev, tracker, out) => {
|
||||||
|
if (ev.id == null) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.guild.left',
|
||||||
|
guildId: ev.id,
|
||||||
|
// `who` is a bare SERIAL string here, not an actor object — the mobile has
|
||||||
|
// already left, so there is nothing for the shard to attribute. The name is
|
||||||
|
// looked up from the roster mirror in `dispatch`.
|
||||||
|
memberSerial: ev.who == null ? null : String(ev.who),
|
||||||
|
data: defined({ guildName: ev.name || `guild ${ev.id}` }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'guild.remove': (ev, tracker, out) => {
|
||||||
|
if (ev.id == null) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.guild.disbanded',
|
||||||
|
guildId: ev.id,
|
||||||
|
// The frame carries ONLY the id, so the name comes from the board mirror in
|
||||||
|
// `dispatch` — and it has to be read there before `applyStateChange` drops
|
||||||
|
// the row, the same ordering `account.unlinked` depends on.
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'city.update': (ev, tracker, out) => {
|
||||||
|
const { city } = ev
|
||||||
|
if (!city) return
|
||||||
|
|
||||||
|
// A new governor. Never on FIRST sight (`prev === undefined`), so a reconnect
|
||||||
|
// snapshot is not read as eight simultaneous elections.
|
||||||
|
const gov = ev.governor && ev.governor.serial != null ? String(ev.governor.serial) : null
|
||||||
|
const prevGov = tracker.cityGovernor.get(city)
|
||||||
|
tracker.cityGovernor.set(city, gov)
|
||||||
|
if (prevGov !== undefined && gov && gov !== prevGov) {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.governor.elected',
|
||||||
|
data: defined({
|
||||||
|
city: String(city),
|
||||||
|
governorName: actorName(ev.governor) || 'a new governor',
|
||||||
|
previousGovernorName: undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
|
||||||
|
// change that arrives without one is not emitted at all rather than emitted
|
||||||
|
// as a deadline-less call to action — which is what a "vote now" mail with
|
||||||
|
// nothing to act by would be.
|
||||||
|
const phase = ev.electionPhase || 'none'
|
||||||
|
const prevPhase = tracker.cityPhase.get(city)
|
||||||
|
tracker.cityPhase.set(city, phase)
|
||||||
|
if (
|
||||||
|
prevPhase !== undefined
|
||||||
|
&& phase !== prevPhase
|
||||||
|
&& (phase === 'nominate' || phase === 'vote')
|
||||||
|
&& ev.autoPickAt
|
||||||
|
) {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.election.opened',
|
||||||
|
data: defined({
|
||||||
|
city: String(city),
|
||||||
|
phase,
|
||||||
|
autoPickAt: ev.autoPickAt,
|
||||||
|
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Come online now ────────────────────────────────────────────────────
|
||||||
|
'champ.update': (ev, tracker, out) => {
|
||||||
|
const serial = ev.serial == null ? null : String(ev.serial)
|
||||||
|
if (!serial) return
|
||||||
|
const isActive = ev.active === true
|
||||||
|
const wasActive = tracker.champActive.get(serial)
|
||||||
|
tracker.champActive.set(serial, isActive)
|
||||||
|
|
||||||
|
const base = defined({
|
||||||
|
spawnSerial: serial,
|
||||||
|
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||||
|
category: ev.category || undefined,
|
||||||
|
location: place(ev),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (wasActive !== undefined && isActive && wasActive !== true) {
|
||||||
|
out.push({ triggerId: 'uo.champ.started', data: base })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossUp = ev.bossUp === true
|
||||||
|
const wasBossUp = tracker.champBossUp.get(serial)
|
||||||
|
tracker.champBossUp.set(serial, bossUp)
|
||||||
|
if (wasBossUp !== undefined && bossUp && wasBossUp !== true) {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.champ.boss_up',
|
||||||
|
data: defined({ ...base, bossName: ev.boss || undefined }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'champ.remove': (ev, tracker) => {
|
||||||
|
if (ev.serial == null) return
|
||||||
|
tracker.champActive.delete(String(ev.serial))
|
||||||
|
tracker.champBossUp.delete(String(ev.serial))
|
||||||
|
},
|
||||||
|
|
||||||
|
// **`server.hello` fires on every sidecar reconnect, not only on a shard
|
||||||
|
// restart** — which is exactly the flapping this trigger must not amplify. The
|
||||||
|
// tracker's `serverUp` is the guard: a hello while we already believe the shard
|
||||||
|
// is up is a reconnect and emits nothing. The seeded rule's hard cooldown is the
|
||||||
|
// second line of defence, for a shard genuinely bouncing.
|
||||||
|
'server.hello': (ev, tracker, out) => {
|
||||||
|
const wasUp = tracker.serverUp
|
||||||
|
tracker.serverUp = true
|
||||||
|
if (wasUp === true) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.server.up',
|
||||||
|
data: defined({ shardName: ev.shard || undefined }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'server.shutdown': (ev, tracker, out) => {
|
||||||
|
if (tracker.serverUp === false) return
|
||||||
|
tracker.serverUp = false
|
||||||
|
out.push({ triggerId: 'uo.server.down', data: { clean: true } })
|
||||||
|
},
|
||||||
|
|
||||||
|
'server.crashed': (ev, tracker, out) => {
|
||||||
|
if (tracker.serverUp === false) return
|
||||||
|
tracker.serverUp = false
|
||||||
|
out.push({ triggerId: 'uo.server.down', data: { clean: false } })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Leaderboard ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `subscribers` only. `top[]` names a mobile SERIAL and `shard_account_links`
|
||||||
|
// is keyed by game ACCOUNT, so the "you were pushed out" half of §8.6's row is
|
||||||
|
// carved out rather than resolved for whoever happens to be online.
|
||||||
|
'points.board': (ev, tracker, out) => {
|
||||||
|
const system = ev.system
|
||||||
|
const top = Array.isArray(ev.top) ? ev.top : []
|
||||||
|
if (!system || !top.length) return
|
||||||
|
const leader = top.find((e) => e && e.rank === 1) || top[0]
|
||||||
|
if (!leader || leader.serial == null) return
|
||||||
|
const serial = String(leader.serial)
|
||||||
|
const prev = tracker.pointsLeader.get(system)
|
||||||
|
tracker.pointsLeader.set(system, serial)
|
||||||
|
if (prev === undefined || prev === serial) return
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.points.rank_changed',
|
||||||
|
data: defined({
|
||||||
|
system: String(system),
|
||||||
|
systemName: ev.nameString || undefined,
|
||||||
|
leaderName: leader.name || 'a new leader',
|
||||||
|
points: Number.isFinite(leader.points) ? leader.points : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Staff-facing ───────────────────────────────────────────────────────
|
||||||
|
'page.new': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.page.new',
|
||||||
|
data: defined({
|
||||||
|
pageType: String(ev.type || 'Other'),
|
||||||
|
senderName: actorName(ev.sender),
|
||||||
|
message: ev.message || undefined,
|
||||||
|
location: place(ev),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'cheat.fastwalk': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.cheat.detected',
|
||||||
|
data: defined({
|
||||||
|
characterName: actorName(ev.who) || 'an unnamed character',
|
||||||
|
account: actorAcct(ev.who),
|
||||||
|
ip: ev.ip || undefined,
|
||||||
|
detector: 'fastwalk',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Operator-facing ────────────────────────────────────────────────────
|
||||||
|
'audit.set': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.audit.staff_action',
|
||||||
|
data: defined({
|
||||||
|
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
|
||||||
|
action: 'set',
|
||||||
|
detail: ev.prop ? `${ev.prop}: ${ev.old ?? '?'} → ${ev.new ?? '?'}` : undefined,
|
||||||
|
target: ev.target || undefined,
|
||||||
|
origin: 'in-game',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'audit.command': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.audit.staff_action',
|
||||||
|
data: defined({
|
||||||
|
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
|
||||||
|
action: 'command',
|
||||||
|
detail: ev.command ? `${ev.command} ${ev.args || ''}`.trim() : undefined,
|
||||||
|
origin: 'in-game',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'admin.audit': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.audit.staff_action',
|
||||||
|
data: defined({
|
||||||
|
staffName: typeof ev.actor === 'string' ? ev.actor : actorName(ev.actor),
|
||||||
|
action: String(ev.action || 'action'),
|
||||||
|
detail: ev.reason || undefined,
|
||||||
|
target: ev.target || undefined,
|
||||||
|
origin: ev.origin || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'economy.supply': (ev, tracker, out) => {
|
||||||
|
for (const [metric, value, thresholds] of [
|
||||||
|
['gold', ev.gold, GOLD_THRESHOLDS],
|
||||||
|
['accounts', ev.accounts, ACCOUNT_THRESHOLDS],
|
||||||
|
]) {
|
||||||
|
if (!Number.isFinite(value)) continue
|
||||||
|
const band = bandOf(value, thresholds)
|
||||||
|
const prev = tracker.economyBand.get(metric)
|
||||||
|
tracker.economyBand.set(metric, band)
|
||||||
|
// First sighting establishes the band and reports nothing. Otherwise a
|
||||||
|
// sidecar reconnect on a mature shard announces "gold passed a billion"
|
||||||
|
// about a line it crossed months ago.
|
||||||
|
if (prev === undefined || prev === band) continue
|
||||||
|
// The line that was crossed is the HIGHER of the two bands under a rise and
|
||||||
|
// the one just left under a fall, so both directions name the line the
|
||||||
|
// reader is thinking about.
|
||||||
|
const crossed = band > prev ? thresholds[band] : thresholds[prev]
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.economy.milestone',
|
||||||
|
data: defined({
|
||||||
|
metric,
|
||||||
|
value: Math.round(value),
|
||||||
|
threshold: crossed,
|
||||||
|
direction: band > prev ? 'up' : 'down',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'world.save.after': (ev, tracker, out) => {
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.world.saved',
|
||||||
|
data: defined({
|
||||||
|
items: Number.isFinite(ev.items) ? ev.items : undefined,
|
||||||
|
mobiles: Number.isFinite(ev.mobiles) ? ev.mobiles : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one shard event to zero or more engagement events. Pure given `tracker`.
|
||||||
|
*
|
||||||
|
* Exported so the mapping can be tested without a database, exactly as
|
||||||
|
* `shardStreams.mapShardEvent` is.
|
||||||
|
*/
|
||||||
|
function mapShardEvent(event, tracker = defaultTracker) {
|
||||||
|
if (!event || typeof event.kind !== 'string') return []
|
||||||
|
const mapper = MAPPERS[event.kind]
|
||||||
|
if (!mapper) return []
|
||||||
|
const out = []
|
||||||
|
mapper(event, tracker, out)
|
||||||
|
// **Defence in depth, and the exact counterpart of `shardStreams.js`'s
|
||||||
|
// public-allowlist filter.** A target naming an id this module does not declare
|
||||||
|
// cannot be delivered — `emit` would refuse it anyway, throwing in dev and
|
||||||
|
// logging in prod — so catching it here turns a typo into one warning with the
|
||||||
|
// id in it rather than an exception on the ingest path.
|
||||||
|
return out.filter((t) => {
|
||||||
|
if (TRIGGER_IDS.has(t.triggerId)) return true
|
||||||
|
log.warn('mapper produced an undeclared trigger id', { kind: event.kind, triggerId: t.triggerId })
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution and dispatch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn one mapped target into the envelope `ctx.events.emit` takes, or null when
|
||||||
|
* there is nobody to tell.
|
||||||
|
*
|
||||||
|
* This is the half that reaches the database, and it is why the mapping above is
|
||||||
|
* separate: an owner-keyed target names a GAME ACCOUNT and a members-keyed one
|
||||||
|
* names a GUILD, and neither is a website user until something asks.
|
||||||
|
*/
|
||||||
|
async function resolveTarget(target, deps) {
|
||||||
|
const { links, state } = deps
|
||||||
|
const data = { ...target.data }
|
||||||
|
|
||||||
|
// `owner` — one account, one user. An unlinked account is nobody to notify,
|
||||||
|
// which is a normal outcome and not an error: most game accounts on most shards
|
||||||
|
// have never been linked.
|
||||||
|
if (target.ownerAccount) {
|
||||||
|
const link = await links.getByAccount(target.ownerAccount)
|
||||||
|
if (!link || link.user_id == null) return null
|
||||||
|
return { data, ownerUserId: Number(link.user_id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
|
||||||
|
// The owner comes from this module's registry mirror — and this read has to
|
||||||
|
// happen before `applyStateChange` drops the row, which is why ingest calls the
|
||||||
|
// engagement fan-out ahead of the state write.
|
||||||
|
if (target.houseSerial) {
|
||||||
|
const houses = await state.listHouses()
|
||||||
|
const house = houses.find((h) => String(h.serial) === target.houseSerial)
|
||||||
|
if (!house || !house.ownerAcct) return null
|
||||||
|
const link = await links.getByAccount(house.ownerAcct)
|
||||||
|
if (!link || link.user_id == null) return null
|
||||||
|
if (house.name) data.houseName = house.name
|
||||||
|
if (house.region) data.region = house.region
|
||||||
|
return { data, ownerUserId: Number(link.user_id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `members` — the guild's roster, resolved to website users through
|
||||||
|
// `shard_account_links` rather than through the roster's mirrored `web_id`.
|
||||||
|
// The mirror is a copy of what the wire said; the links table is the answer.
|
||||||
|
if (target.guildId != null) {
|
||||||
|
const accounts = await state.listGuildMemberAccounts(target.guildId)
|
||||||
|
const userIds = await links.userIdsForAccounts(accounts)
|
||||||
|
if (!userIds.length) return null
|
||||||
|
|
||||||
|
// Fill in the two names the frames do not carry, from the board mirror.
|
||||||
|
if (!data.guildName || !data.abbreviation) {
|
||||||
|
const guilds = await state.listGuilds()
|
||||||
|
const guild = guilds.find((g) => String(g.id) === String(target.guildId))
|
||||||
|
if (guild) {
|
||||||
|
if (!data.guildName) data.guildName = guild.name || `guild ${target.guildId}`
|
||||||
|
if (guild.abbr && data.abbreviation === undefined) data.abbreviation = guild.abbr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data.guildName) data.guildName = `guild ${target.guildId}`
|
||||||
|
|
||||||
|
// Who left, from the roster mirror — the departing member's row is still
|
||||||
|
// there, because `guild.leave`'s state write has not run yet.
|
||||||
|
if (target.memberSerial) {
|
||||||
|
const members = await state.listGuildMembers(target.guildId)
|
||||||
|
const gone = members.find((m) => String(m.serial) === target.memberSerial)
|
||||||
|
if (gone && gone.name) data.memberName = gone.name
|
||||||
|
}
|
||||||
|
return { data, recipientUserIds: userIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else — `subscribers`, `staff`, `admin` — has no per-event
|
||||||
|
// audience to resolve. The rule's audience is the whole answer.
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan one shard event out to the engagement engine.
|
||||||
|
*
|
||||||
|
* Never throws. Called fire-and-forget from `shardIngest.ingest`, beside the SSE
|
||||||
|
* broadcast and the push dispatch, and held to the same promise all three make:
|
||||||
|
* a slow or failing notification path must never delay or fail ingest.
|
||||||
|
*/
|
||||||
|
async function fromShardEvent(event, deps = {}) {
|
||||||
|
const d = {
|
||||||
|
links: deps.shardLinks || shardLinks,
|
||||||
|
state: deps.shardState || shardState,
|
||||||
|
emit: deps.emit || core.events.emit,
|
||||||
|
tracker: deps.tracker || defaultTracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of mapShardEvent(event, d.tracker)) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveTarget(target, d)
|
||||||
|
// Nobody to tell. Not an error and deliberately not logged at warn: an
|
||||||
|
// unlinked house owner is the common case on every shard.
|
||||||
|
if (!resolved) continue
|
||||||
|
d.emit(target.triggerId, {
|
||||||
|
data: resolved.data,
|
||||||
|
...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}),
|
||||||
|
...(resolved.recipientUserIds ? { recipientUserIds: resolved.recipientUserIds } : {}),
|
||||||
|
...(target.dedupeKey ? { dedupeKey: target.dedupeKey } : {}),
|
||||||
|
occurredAt: Number.isFinite(event.t) ? new Date(event.t) : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('engagement target failed', { triggerId: target.triggerId, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fromShardEvent,
|
||||||
|
mapShardEvent,
|
||||||
|
createTracker,
|
||||||
|
reset,
|
||||||
|
VENDOR_WARN_HOURS,
|
||||||
|
GOLD_THRESHOLDS,
|
||||||
|
ACCOUNT_THRESHOLDS,
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|||||||
const { settings: settingsModel } = require('../core')
|
const { settings: settingsModel } = require('../core')
|
||||||
const broadcaster = require('./shardBroadcast')
|
const broadcaster = require('./shardBroadcast')
|
||||||
const shardPush = require('./shardPush')
|
const shardPush = require('./shardPush')
|
||||||
|
const shardEngagement = require('./shardEngagement')
|
||||||
const defaultLog = require('../core').logger('shard-ingest')
|
const defaultLog = require('../core').logger('shard-ingest')
|
||||||
|
|
||||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
// 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 }
|
const state = { bootId: null }
|
||||||
function reset() {
|
function reset() {
|
||||||
state.bootId = null
|
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?
|
// Should this event be written to the append-only log?
|
||||||
@@ -285,6 +291,7 @@ function resolveDeps(deps) {
|
|||||||
settings: deps.settings || settingsModel,
|
settings: deps.settings || settingsModel,
|
||||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||||
|
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
||||||
log: deps.log || defaultLog,
|
log: deps.log || defaultLog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +307,34 @@ async function ingest(event, deps = {}) {
|
|||||||
let stored = false
|
let stored = false
|
||||||
let logged = 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 {
|
try {
|
||||||
await applyStateChange(event, d)
|
await applyStateChange(event, d)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user