feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edge → main)
#26
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/model/ one directory per table family; nothing crosses the boundary
|
||||
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
|
||||
server/config/ the push stream catalog
|
||||
server/config/ the push stream catalog, the engagement triggers and audiences
|
||||
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||
server/scripts/ the three checks: imports, the fragment, the frozen manifest
|
||||
@@ -112,6 +112,14 @@ client/vite.config.js the library build, the aliases, the not-bundled guard
|
||||
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
|
||||
```
|
||||
|
||||
**What this module registers with core, beyond its routes.** Seven push streams, one announce leg
|
||||
(the in-game town crier), a Team provider (a UO guild is a Team), one slash command, and — since
|
||||
ENGAGEMENT.md Phase 11 — **24 engagement triggers and 3 audiences**. A trigger is a payload contract:
|
||||
what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever
|
||||
give it. Core learns none of the vocabulary; it holds ids, labels and ceilings. Declaring a trigger
|
||||
sends nobody anything — every rule ships disabled. The catalogue, the four rows deliberately absent
|
||||
and the reasons are in [`docs/modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md) §5.
|
||||
|
||||
**The three generated files are committed on purpose.** Two of them are what core reads instead of
|
||||
looking at this source — it never has it — and the third records which core they were proved against.
|
||||
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
||||
"refName": "main @ the Teams cutover (website#161)"
|
||||
"ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9",
|
||||
"refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.3.0",
|
||||
"coreApi": "^1.3.0",
|
||||
"version": "0.5.0",
|
||||
"coreApi": "^1.9.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
46
server/config/clientPaths.js
Normal file
46
server/config/clientPaths.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// ── The module's own client paths, in one place ────────────────────────────
|
||||
//
|
||||
// Every link a notification puts in front of a player is a path into this
|
||||
// module's SPA routes, and Phase 11b's live walk found that not one of them was
|
||||
// right: the declared examples all read `/shard/…` (module.json's `mounts`), the
|
||||
// bodies hard-coded a mixture of `/shard/…` and `/player/uo/…`, and the mapper
|
||||
// populated none of the URL variables at all — so every in-universe letter shipped
|
||||
// with an empty href and every template preview showed a dead one.
|
||||
//
|
||||
// **The prefix is the module ID, not the mount.** `registry.registerRoutes`
|
||||
// prefixes a module's client routes with `<id>/` and nothing else
|
||||
// (`client/src/modules/registry.js`), which is why `module.json`'s `mounts` is not
|
||||
// the answer — that field says what the module CLAIMS, and the router says where
|
||||
// it landed. `client/src/entry.jsx`'s own `registerNav` is the check: the hrefs it
|
||||
// gives the sidebar are these, and if the two ever disagree the sidebar is right.
|
||||
//
|
||||
// Kept server-side and shared by BOTH the trigger declarations (their `example`s,
|
||||
// which the template editor previews and test-sends with) and the seeded bodies,
|
||||
// so a route that moves is one edit rather than thirty.
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
const PATHS = {
|
||||
shard: `/${ID}/shard`,
|
||||
champs: `/${ID}/champs`,
|
||||
guilds: `/${ID}/guilds`,
|
||||
governors: `/${ID}/governors`,
|
||||
houses: `/${ID}/houses`,
|
||||
atlas: `/${ID}/atlas`,
|
||||
leaderboards: `/${ID}/leaderboards`,
|
||||
market: `/${ID}/market`,
|
||||
// Self-service and staff areas sit under core's own wrappers, so they carry
|
||||
// core's prefix as well as the module's.
|
||||
characters: `/player/${ID}/characters`,
|
||||
ops: `/admin/${ID}/ops`,
|
||||
}
|
||||
|
||||
/** One guild's roster, when the frame names a guild; the list otherwise. */
|
||||
const guildPath = (guildId) =>
|
||||
(guildId === undefined || guildId === null ? PATHS.guilds : `${PATHS.guilds}/${guildId}`)
|
||||
|
||||
/** One vendor's page, when the frame names one; the market otherwise. */
|
||||
const vendorPath = (serial) =>
|
||||
(serial ? `${PATHS.market}/vendors/${serial}` : PATHS.market)
|
||||
|
||||
module.exports = { PATHS, guildPath, vendorPath }
|
||||
969
server/config/engagementSeeds.js
Normal file
969
server/config/engagementSeeds.js
Normal file
@@ -0,0 +1,969 @@
|
||||
// ── module-uo's shipped message bodies and rules ───────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 11b, decisions 8, 9 and 10; the mechanism is decision 7's
|
||||
// `api.registerEngagementSeeds` (MODULE_API.md §1.1, 1.9.0). `shardTriggers.js`
|
||||
// says what an event IS and who it is about; this file says what the message
|
||||
// READS like, and which rules an operator finds waiting on the Rules screen.
|
||||
//
|
||||
// ── Why any of this is bespoke at all ──────────────────────────────────────
|
||||
//
|
||||
// §4.6.1 property 1 is that a trigger needs NO authoring: `notify.event` plus the
|
||||
// structural projection renders any declaration as a title, an intro and a link.
|
||||
// That property is real and nine of these twenty-five triggers use it — see
|
||||
// PLAIN below. What it cannot do is have a voice, and the org lead's decision 8
|
||||
// is that the game-powered families should read from inside Britannia rather than
|
||||
// from a notifications system.
|
||||
//
|
||||
// **The sender is per family, not one voice across all sixteen**, and that was
|
||||
// the decision rather than the obvious answer. Lord Blackthorn writing to you
|
||||
// personally about a champion spawn is a shard where the letter about your
|
||||
// governorship means nothing. So the court writes about the crown's business —
|
||||
// the seat, the ballot — and everything else has the sender its own subject
|
||||
// implies:
|
||||
//
|
||||
// the Office of Deeds houses a clerk with a ledger and a duty to warn
|
||||
// the Merchants' Guild vendors a factor rendering accounts
|
||||
// a guild herald guild events
|
||||
// the town crier champion spawns
|
||||
// a guildmaster skills, quests
|
||||
// the Chronicler deaths
|
||||
// the keeper of the rolls leaderboards
|
||||
// Lord Blackthorn's court governors, elections
|
||||
//
|
||||
// **Nine bodies stay PLAIN, and the line is drawn where fiction costs something
|
||||
// real** (decision 9). A failed-login notice written as "a stranger sought entry
|
||||
// to thy account" is indistinguishable in register from the phishing mail it
|
||||
// warns about, and an operator reading `uo.cheat.detected` at two in the morning
|
||||
// wants a name, a rule and a timestamp rather than a scroll. Those nine name
|
||||
// core's `notify.event` / `inapp.event` and author nothing.
|
||||
//
|
||||
// **Both channels, and the digest deliberately neither.** Each in-universe
|
||||
// trigger ships an `email` body (the letter) and an `inapp` body in the same
|
||||
// voice, because one rule fires on both at once and a player who reads the inbox
|
||||
// item and then the mail must not meet two different narrators. The DIGEST stays
|
||||
// core's generic `notify.digest`: a day of events rolled into one list is not a
|
||||
// letter from anybody, and dressing a bulleted summary as correspondence is where
|
||||
// this device stops being charming.
|
||||
//
|
||||
// ── Three things to know before editing a body ─────────────────────────────
|
||||
//
|
||||
// 1. **No conditionals, ever.** An unset optional interpolates to the EMPTY
|
||||
// STRING (`interpolate.js`), so a sentence built around one gets a hole in
|
||||
// it. The fragments `shardTriggers.js` declares — `houseLabel`, `slainBy`,
|
||||
// `atPlace` — exist for exactly this and are the only safe way to put an
|
||||
// optional inside a clause. A trailing fragment carries its OWN leading
|
||||
// space; do not add one.
|
||||
// 2. **No brand, no colour, no logo** (§4.6.1 property 2). `siteName`,
|
||||
// `siteUrl`, `logoUrl` and `year` are ambient and supplied by the renderer,
|
||||
// so one prebuilt image mails in whatever shard's identity it is running as.
|
||||
// An in-universe body is UO-specific and still shard-agnostic.
|
||||
// 3. **`seedVersion` is the "improve a default without stealing an operator's
|
||||
// work" mechanism.** Bump it when a body changes and the seeder updates
|
||||
// rows where `customized = 0` and skips rows where it is 1. Do NOT bump it
|
||||
// for a comment.
|
||||
//
|
||||
// An operator running a shard whose canon is not Blackthorn's edits these rows;
|
||||
// that is what the template editor is for, and `customized = 1` then protects the
|
||||
// edit from every later seed.
|
||||
|
||||
// ── Block helpers, so the bodies below read as content ─────────────────────
|
||||
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({
|
||||
id,
|
||||
type: 'email.heading',
|
||||
props: { level, text: body },
|
||||
})
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
// The unsubscribe pair every in-universe EMAIL body ends with. In the plain
|
||||
// register on purpose: an unsubscribe link is a legal and practical affordance,
|
||||
// not part of the fiction, and a reader hunting for it should not have to parse a
|
||||
// herald to find it.
|
||||
const unsubscribe = () => [
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
|
||||
]
|
||||
|
||||
|
||||
/** An email body: subject line, blocks, the unsubscribe pair appended. */
|
||||
const email = (key, name, triggerId, subject, blocks) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'email',
|
||||
triggerId,
|
||||
seedVersion: 1,
|
||||
subject,
|
||||
blocks: [...blocks, ...unsubscribe()],
|
||||
})
|
||||
|
||||
/**
|
||||
* An in-app body — the same voice, three blocks.
|
||||
*
|
||||
* The renderer maps them onto `user_notifications` BY ROLE (`renderInappByKey`):
|
||||
* the heading is the row's title, the button is its one action, everything else
|
||||
* is the body. No unsubscribe line: an inbox item links to the preferences screen
|
||||
* that an unsubscribe link would only reach anyway.
|
||||
*/
|
||||
const inapp = (key, name, triggerId, title, body, action, url) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'inapp',
|
||||
triggerId,
|
||||
seedVersion: 1,
|
||||
subject: null,
|
||||
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
|
||||
})
|
||||
|
||||
// ── The sixteen in-universe bodies ─────────────────────────────────────────
|
||||
|
||||
const { PATHS } = require('./clientPaths')
|
||||
|
||||
const TEMPLATES = [
|
||||
// ── The Office of Deeds — houses ────────────────────────────────────────
|
||||
//
|
||||
// A clerk, not a poet. The register is bureaucratic-formal because that is what
|
||||
// makes the WARNING land: an office that keeps a ledger and is obliged to tell
|
||||
// you before the ledger is amended.
|
||||
email(
|
||||
'uo.house.idoc-warning',
|
||||
'House — decay warning (Office of Deeds)',
|
||||
'uo.house.idoc_warning',
|
||||
// `houseLabel`, not `{{region}}`: a subject line is the one place a hole is
|
||||
// unmissable, and a house outside a named region rendered “thy house at ”.
|
||||
// A LABEL always has a value; that is what separates it from a fragment.
|
||||
'A notice concerning {{houseLabel}}',
|
||||
[
|
||||
heading('h', 'From the Office of Deeds'),
|
||||
text('p1',
|
||||
'Be it known that {{houseLabel}}, recorded to thy name, is this day found {{stageLabel}}. '
|
||||
+ 'A house left untended passes in time out of thy keeping, and the deed with it.'),
|
||||
text('p2',
|
||||
'Visit the house and refresh it, and the ledger is set right. This office keeps no '
|
||||
+ 'record of a house once it has fallen.'),
|
||||
text('where', '{{whereLine}}', { muted: true }),
|
||||
button('cta', 'Review thy holdings', '{{houseUrl}}', 'Thy holdings are listed here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.house.idoc-warning-inapp',
|
||||
'House — decay warning (in-app)',
|
||||
'uo.house.idoc_warning',
|
||||
'The Office of Deeds sends word',
|
||||
'{{houseLabel}} is found {{stageLabel}}. Refresh it, or in time it passes out of thy keeping.',
|
||||
'Review thy holdings',
|
||||
'{{houseUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.house.collapsed',
|
||||
'House — collapsed (Office of Deeds)',
|
||||
'uo.house.collapsed',
|
||||
'The deed to thy house has been struck from the ledger',
|
||||
[
|
||||
heading('h', 'From the Office of Deeds'),
|
||||
text('p1',
|
||||
'It falls to this office to inform thee that {{houseLabel}} has fallen, and the deed '
|
||||
+ 'recorded to thy name is struck from the ledger.'),
|
||||
text('p2',
|
||||
'What stood within is scattered where it stood, and the ground is open to any who would '
|
||||
+ 'build there. This office is able to restore nothing.'),
|
||||
text('where', '{{whereLine}}', { muted: true }),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.house.collapsed-inapp',
|
||||
'House — collapsed (in-app)',
|
||||
'uo.house.collapsed',
|
||||
'Thy house has fallen',
|
||||
'{{houseLabel}} has fallen, and the deed is struck from the ledger. The ground is open to any who would build there.',
|
||||
'Review thy holdings',
|
||||
PATHS.houses,
|
||||
),
|
||||
|
||||
// The one letter this office sends that is not a warning (Phase 11b decision
|
||||
// 11). It is the same clerk and the same ledger, which is the point: an office
|
||||
// that only ever writes when something is wrong teaches a reader to dread its
|
||||
// seal, and the notice that the ledger is set right is the cheapest possible
|
||||
// way not to. It is also why `uo.house.refreshed` is a trigger at all — the
|
||||
// cancellation is the mechanism, this is the message.
|
||||
email(
|
||||
'uo.house.refreshed',
|
||||
'House — refreshed (Office of Deeds)',
|
||||
'uo.house.refreshed',
|
||||
'The ledger is set right for {{houseLabel}}',
|
||||
[
|
||||
heading('h', 'From the Office of Deeds'),
|
||||
text('p1',
|
||||
'This office records that {{houseLabel}}, held in thy name, has been refreshed and '
|
||||
+ 'stands in good repair.{{fromLine}}'),
|
||||
text('p2',
|
||||
'No further notice will be sent concerning it. Should it fall into disrepair again, '
|
||||
+ 'thou wilt hear from us before the deed is touched.'),
|
||||
button('cta', 'Review thy holdings', '{{houseUrl}}', 'Thy holdings are listed here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.house.refreshed-inapp',
|
||||
'House — refreshed (in-app)',
|
||||
'uo.house.refreshed',
|
||||
'The Office of Deeds sends word',
|
||||
'{{houseLabel}} has been refreshed and stands in good repair.{{fromLine}}',
|
||||
'Review thy holdings',
|
||||
'{{houseUrl}}',
|
||||
),
|
||||
|
||||
// ── The Merchants' Guild — vendors ──────────────────────────────────────
|
||||
//
|
||||
// A factor rendering accounts: precise about money, unsentimental about
|
||||
// consequence. The numbers are the point of the message, so they are in the
|
||||
// body rather than in a muted footnote.
|
||||
email(
|
||||
'uo.vendor.expiring',
|
||||
'Vendor — fees due (Merchants’ Guild)',
|
||||
'uo.vendor.expiring',
|
||||
'Accounts outstanding on {{shopLabel}}',
|
||||
[
|
||||
heading('h', 'From the Merchants’ Guild'),
|
||||
text('p1',
|
||||
'Good day. The Guild renders accounts on {{shopLabel}}, and finds them wanting. '
|
||||
+ 'Some {{hoursRemaining}} hours remain before the keeper is dismissed and the wares '
|
||||
+ 'returned whence they came.'),
|
||||
text('p2',
|
||||
'A deposit set against the account settles the matter. The Guild holds no goods for a '
|
||||
+ 'merchant who has ceased to pay for their keeping.'),
|
||||
text('ledger', '{{ledgerLine}}', { muted: true }),
|
||||
button('cta', 'Attend to thy shop', '{{marketUrl}}', 'Thy shop stands here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.vendor.expiring-inapp',
|
||||
'Vendor — fees due (in-app)',
|
||||
'uo.vendor.expiring',
|
||||
'The Merchants’ Guild renders accounts',
|
||||
'{{shopLabel}} has some {{hoursRemaining}} hours before the keeper is dismissed and the wares returned. A deposit settles it.',
|
||||
'Attend to thy shop',
|
||||
'{{marketUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.vendor.sale',
|
||||
'Vendor — a sale (Merchants’ Guild)',
|
||||
'uo.vendor.sale',
|
||||
'A sale is entered against {{shopLabel}}',
|
||||
[
|
||||
heading('h', 'From the Merchants’ Guild'),
|
||||
text('p1',
|
||||
'The Guild enters a sale against {{shopLabel}}: {{itemLine}}, for {{price}} gold.'),
|
||||
text('p2',
|
||||
'The takings are held by thy keeper until thou callest for them.'),
|
||||
text('ledger', '{{ledgerLine}}', { muted: true }),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.vendor.sale-inapp',
|
||||
'Vendor — a sale (in-app)',
|
||||
'uo.vendor.sale',
|
||||
'A sale at thy shop',
|
||||
'{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.',
|
||||
'Open the market',
|
||||
PATHS.market,
|
||||
),
|
||||
|
||||
// ── A guild herald ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Announcements to a body of people rather than to a person, which is what the
|
||||
// `members` audience is — so the second person plural, and no "thy".
|
||||
email(
|
||||
'uo.guild.left',
|
||||
'Guild — a member departs (herald)',
|
||||
'uo.guild.left',
|
||||
'A departure from {{guildName}}',
|
||||
[
|
||||
heading('h', 'A notice to the company'),
|
||||
text('p1',
|
||||
'{{memberLabel}} is no longer counted among {{guildName}}. The rolls have been amended.'),
|
||||
button('cta', 'Read the roll', '{{guildUrl}}', 'The roll stands here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.guild.left-inapp',
|
||||
'Guild — a member departs (in-app)',
|
||||
'uo.guild.left',
|
||||
'A departure from {{guildName}}',
|
||||
'{{memberLabel}} is no longer counted among the company. The rolls have been amended.',
|
||||
'Read the roll',
|
||||
'{{guildUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.guild.disbanded',
|
||||
'Guild — disbanded (herald)',
|
||||
'uo.guild.disbanded',
|
||||
'{{guildName}} is dissolved',
|
||||
[
|
||||
heading('h', 'A notice to the company'),
|
||||
text('p1',
|
||||
'Be it known that {{guildName}} is dissolved. Its charter is void, its rolls are closed, '
|
||||
+ 'and those who wore its colours wear them no longer.'),
|
||||
text('p2',
|
||||
'What was held in common is held in common no more.'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.guild.disbanded-inapp',
|
||||
'Guild — disbanded (in-app)',
|
||||
'uo.guild.disbanded',
|
||||
'{{guildName}} is dissolved',
|
||||
'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.',
|
||||
'Open the shard',
|
||||
PATHS.shard,
|
||||
),
|
||||
|
||||
// ── Lord Blackthorn's court — the crown's business ──────────────────────
|
||||
//
|
||||
// **The letter the whole voice decision was chosen to make possible**
|
||||
// (decision 10). Note what it is NOT: it is not the town's bulletin. The
|
||||
// announcement below it says a city has a governor; this says a person has a
|
||||
// duty. They are two rules and two bodies for exactly that reason.
|
||||
email(
|
||||
'uo.governor.appointed',
|
||||
'Governor — thy appointment (the court)',
|
||||
'uo.governor.appointed',
|
||||
'The seat of {{city}} passes to thee',
|
||||
[
|
||||
heading('h', 'By the hand of Lord Blackthorn'),
|
||||
text('p1',
|
||||
'{{governorName}} — the people of {{city}} have named thee their Governor{{inSuccessionTo}}, '
|
||||
+ 'and the Crown confirms it.'),
|
||||
text('p2',
|
||||
'The seat carries duties as well as honours. A city is judged by what its Governor '
|
||||
+ 'troubles to build, and by what is allowed to fall into disrepair while they hold '
|
||||
+ 'the office. See that {{city}} is the better for thy tenure.'),
|
||||
text('p3',
|
||||
'The Crown will not govern in thy stead, nor will it stand between thee and those who '
|
||||
+ 'gave thee the seat. They may take it back.'),
|
||||
button('cta', 'Take up the seat', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.governor.appointed-inapp',
|
||||
'Governor — thy appointment (in-app)',
|
||||
'uo.governor.appointed',
|
||||
'Thou art named Governor of {{city}}',
|
||||
'The people of {{city}} have named thee their Governor{{inSuccessionTo}}, and the Crown confirms it. The seat carries duties as well as honours.',
|
||||
'Take up the seat',
|
||||
'{{governorsUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.governor.elected',
|
||||
'Governor — a city decides (the court)',
|
||||
'uo.governor.elected',
|
||||
'{{city}} has named a Governor',
|
||||
[
|
||||
heading('h', 'Proclaimed from the court of Lord Blackthorn'),
|
||||
text('p1',
|
||||
'Let it be known throughout the realm that the people of {{city}} have named '
|
||||
+ '{{governorName}} their Governor{{inSuccessionTo}}.'),
|
||||
text('p2',
|
||||
'Those with business in {{city}} may address it to the new seat.'),
|
||||
button('cta', 'See the offices of the realm', '{{governorsUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.governor.elected-inapp',
|
||||
'Governor — a city decides (in-app)',
|
||||
'uo.governor.elected',
|
||||
'{{city}} has named a Governor',
|
||||
'{{governorName}} holds the seat of {{city}}{{inSuccessionTo}}. Those with business there may address it to the new seat.',
|
||||
'See the offices of the realm',
|
||||
'{{governorsUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.election.opened',
|
||||
'Election — the ballot opens (the court)',
|
||||
'uo.election.opened',
|
||||
'{{phaseLabel}} in {{city}}',
|
||||
[
|
||||
heading('h', 'Proclaimed from the court of Lord Blackthorn'),
|
||||
text('p1',
|
||||
'{{phaseLabel}} in {{city}}.{{candidateNote}}'),
|
||||
text('p2',
|
||||
'Those who hold the loyalty of the city may speak. Attend before {{autoPickWhen}}: '
|
||||
+ 'after that hour the matter is decided without thee, and the Crown will hear no '
|
||||
+ 'complaint from any who could have spoken and did not.'),
|
||||
button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.election.opened-inapp',
|
||||
'Election — the ballot opens (in-app)',
|
||||
'uo.election.opened',
|
||||
'{{phaseLabel}} in {{city}}',
|
||||
'Attend before {{autoPickWhen}} — after that hour the matter is decided without thee.{{candidateNote}}',
|
||||
'Attend the city',
|
||||
'{{governorsUrl}}',
|
||||
),
|
||||
|
||||
// ── The town crier — come and see ───────────────────────────────────────
|
||||
//
|
||||
// Short, loud, and about NOW. A crier does not write letters; these two are the
|
||||
// shortest bodies in the file on purpose, because their whole job is to get
|
||||
// somebody to log in within the hour.
|
||||
email(
|
||||
'uo.champ.started',
|
||||
'Champion spawn — begun (town crier)',
|
||||
'uo.champ.started',
|
||||
'Hear ye — {{spawnName}} stirs',
|
||||
[
|
||||
heading('h', 'Hear ye, hear ye'),
|
||||
text('p1',
|
||||
'Word from the roads: {{spawnName}} stirs{{atPlace}}. Those with the stomach for it '
|
||||
+ 'had best go now — such things do not wait.'),
|
||||
button('cta', 'See what stirs', '{{champsUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.champ.started-inapp',
|
||||
'Champion spawn — begun (in-app)',
|
||||
'uo.champ.started',
|
||||
'{{spawnName}} stirs',
|
||||
'Word from the roads: {{spawnName}} stirs{{atPlace}}. Such things do not wait.',
|
||||
'See what stirs',
|
||||
'{{champsUrl}}',
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.champ.boss-up',
|
||||
'Champion spawn — the champion walks (town crier)',
|
||||
'uo.champ.boss_up',
|
||||
'Hear ye — the champion of {{spawnName}} walks',
|
||||
[
|
||||
heading('h', 'Hear ye, hear ye'),
|
||||
text('p1',
|
||||
'{{bossName}} walks{{atPlace}}. The lesser things are spent; what remains is the '
|
||||
+ 'reason anyone came.'),
|
||||
button('cta', 'See what walks', '{{champsUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.champ.boss-up-inapp',
|
||||
'Champion spawn — the champion walks (in-app)',
|
||||
'uo.champ.boss_up',
|
||||
'The champion of {{spawnName}} walks',
|
||||
'{{bossName}} walks{{atPlace}}. The lesser things are spent.',
|
||||
'See what walks',
|
||||
'{{champsUrl}}',
|
||||
),
|
||||
|
||||
// ── A guildmaster of the craft ──────────────────────────────────────────
|
||||
email(
|
||||
'uo.skill.capped',
|
||||
'Skill — mastery reached (guildmaster)',
|
||||
'uo.skill.capped',
|
||||
'{{characterName}} has mastered {{skill}}',
|
||||
[
|
||||
heading('h', 'From the guildmaster of {{skill}}'),
|
||||
text('p1',
|
||||
'{{characterName}} — thou hast carried {{skill}} as far as it will be carried. '
|
||||
+ '{{cap}} is the whole of it; there is no further mark to reach.'),
|
||||
text('p2',
|
||||
'What thou dost with it is thine own affair. The guild has taught thee what it knows.'),
|
||||
button('cta', 'Read thy character', PATHS.characters),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.skill.capped-inapp',
|
||||
'Skill — mastery reached (in-app)',
|
||||
'uo.skill.capped',
|
||||
'{{characterName}} has mastered {{skill}}',
|
||||
'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.',
|
||||
'Read thy character',
|
||||
PATHS.characters,
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.quest.complete',
|
||||
'Quest — completed (guildmaster)',
|
||||
'uo.quest.complete',
|
||||
'{{characterName}} has seen {{quest}} through',
|
||||
[
|
||||
heading('h', 'A matter concluded'),
|
||||
text('p1',
|
||||
'{{characterName}} has seen {{quest}} through to its end. It is written down, which is '
|
||||
+ 'more than most who set out on it can say.'),
|
||||
button('cta', 'Read thy character', PATHS.characters),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.quest.complete-inapp',
|
||||
'Quest — completed (in-app)',
|
||||
'uo.quest.complete',
|
||||
'{{quest}} — concluded',
|
||||
'{{characterName}} has seen {{quest}} through to its end. It is written down.',
|
||||
'Read thy character',
|
||||
PATHS.characters,
|
||||
),
|
||||
|
||||
// ── The Chronicler of the Dead ──────────────────────────────────────────
|
||||
//
|
||||
// Dry to the point of dark, and deliberately so: this is a killfeed some
|
||||
// players want and most do not (§8.6), so its rule ships off and its body reads
|
||||
// as a clerk making an entry rather than as the game commiserating.
|
||||
email(
|
||||
'uo.character.death',
|
||||
'Death — an entry (the Chronicler)',
|
||||
'uo.character.death',
|
||||
'An entry concerning {{characterName}}',
|
||||
[
|
||||
heading('h', 'From the Chronicle of the Dead'),
|
||||
text('p1',
|
||||
'An entry is made: {{characterName}} has fallen{{slainBy}}.'),
|
||||
text('p2',
|
||||
'The Chronicle notes the fact and offers no opinion on it. Britannia is generous with '
|
||||
+ 'second chances and keeps a record of every one.'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.character.death-inapp',
|
||||
'Death — an entry (in-app)',
|
||||
'uo.character.death',
|
||||
'{{characterName}} has fallen',
|
||||
'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.',
|
||||
'Read thy character',
|
||||
PATHS.characters,
|
||||
),
|
||||
|
||||
email(
|
||||
'uo.character.murdered',
|
||||
'Murder — an entry (the Chronicler)',
|
||||
'uo.character.murdered',
|
||||
'A murder is entered concerning {{characterName}}',
|
||||
[
|
||||
heading('h', 'From the Chronicle of the Dead'),
|
||||
text('p1',
|
||||
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.'),
|
||||
text('p2',
|
||||
'The Chronicle records the name of the guilty where it is known. What is done with '
|
||||
+ 'that name is a matter for the living.'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.character.murdered-inapp',
|
||||
'Murder — an entry (in-app)',
|
||||
'uo.character.murdered',
|
||||
'{{characterName}} was murdered',
|
||||
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.',
|
||||
'Read thy character',
|
||||
PATHS.characters,
|
||||
),
|
||||
|
||||
// ── The keeper of the rolls ─────────────────────────────────────────────
|
||||
email(
|
||||
'uo.points.rank-changed',
|
||||
'Leaderboard — the first place changes (keeper of the rolls)',
|
||||
'uo.points.rank_changed',
|
||||
'A new name heads the roll of {{boardLabel}}',
|
||||
[
|
||||
heading('h', 'From the keeper of the rolls'),
|
||||
text('p1',
|
||||
'The roll of {{boardLabel}} is amended. {{standingLine}}'),
|
||||
text('p2',
|
||||
'A roll is only ever the state of a thing on the day it was read.'),
|
||||
button('cta', 'Read the roll', PATHS.leaderboards),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'uo.points.rank-changed-inapp',
|
||||
'Leaderboard — the first place changes (in-app)',
|
||||
'uo.points.rank_changed',
|
||||
'A new name heads {{boardLabel}}',
|
||||
'The roll of {{boardLabel}} is amended. {{standingLine}}',
|
||||
'Read the roll',
|
||||
PATHS.leaderboards,
|
||||
),
|
||||
]
|
||||
|
||||
// **`unsubscribeUrl` is not declared here, and that is core's doing.** A
|
||||
// trigger-bound template takes its variable list from the TRIGGER's declaration
|
||||
// (`templates.variablesFor`), and a trigger has no business declaring a fact
|
||||
// about how the mail was delivered — so core adds the per-delivery variables to
|
||||
// that path (`templateSeeds.DELIVERY_VARIABLES`, added in this same phase).
|
||||
// Without it the bodies below would render their unsubscribe link correctly and
|
||||
// then refuse the first operator who tried to EDIT one, on the save-time
|
||||
// undeclared-variable check.
|
||||
|
||||
// ── The twenty-five rules, every one of them off ───────────────────────────
|
||||
//
|
||||
// **`enabled = 0` is not a parameter** (Q3) — `registerEngagementSeeds` ignores
|
||||
// any value passed for it. This is a catalogue an operator turns on, not a switch
|
||||
// that floods anybody the day they upgrade.
|
||||
//
|
||||
// **One rule group, `triggers-v1`, and the choice matters** (MODULE_API 1.9.0). A
|
||||
// group is seeded ONCE, so a rule appended to this list later reaches fresh
|
||||
// installs only. That is correct for this set — it is the module's first — and it
|
||||
// is exactly the trap 11a's seed-key finding names: a twenty-sixth trigger added
|
||||
// in a future version wants its OWN group, or the deployments that most need it
|
||||
// will never see it.
|
||||
//
|
||||
// The generic body is named deliberately wherever it appears. `notify.event` plus
|
||||
// the structural projection is the right answer for a message whose content is
|
||||
// "this happened, here is the link", and nine of these rules say so.
|
||||
|
||||
const CHANNELS_OWNER = ['email', 'inapp']
|
||||
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
|
||||
|
||||
/** In-universe: both bodies are this module's, the digest is core's. */
|
||||
const bodies = (key) => ({
|
||||
email: `uo.${key}`,
|
||||
inapp: `uo.${key}-inapp`,
|
||||
digest: 'notify.digest',
|
||||
})
|
||||
|
||||
/** Plain: core's generic bodies, no authoring (§4.6.1 property 1). */
|
||||
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
|
||||
|
||||
const RULES = [
|
||||
// ── Owned asset at risk ────────────────────────────────────────────────
|
||||
{
|
||||
trigger_id: 'uo.house.idoc_warning',
|
||||
name: 'House — decay warning',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('house.idoc-warning'),
|
||||
// A day, per HOUSE (the trigger's `subjectKey`). A house crossing two stages
|
||||
// in an afternoon is one warning; a player with three decaying houses still
|
||||
// hears about all three, which is the case `subjectKey` exists for.
|
||||
cooldown_seconds: 86_400,
|
||||
// **A quarter of an hour of grace, and something that cancels it.** A player
|
||||
// who is standing in the house when it ticks over refreshes it within
|
||||
// seconds; mailing them anyway is how a warning system teaches people to
|
||||
// ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this.
|
||||
delay_seconds: 900,
|
||||
// **Both outcomes, and the refresh is the one the delay is FOR.** A collapse
|
||||
// inside the window makes the warning pointless; a refresh inside it makes
|
||||
// the warning wrong. Phase 11b's live walk found that only the first was
|
||||
// named here, so the good outcome — the player fixing the thing they were
|
||||
// about to be warned about — still produced the letter.
|
||||
cancel_on: ['uo.house.collapsed', 'uo.house.refreshed'],
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.house.refreshed',
|
||||
name: 'House — refreshed',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('house.refreshed'),
|
||||
// A day, per house, like the warning it answers — a player refreshing the
|
||||
// same house twice in an afternoon does not need telling twice. No delay:
|
||||
// there is no bad outcome this could be waiting to be overtaken by.
|
||||
//
|
||||
// **This rule is not what does the cancelling.** `cancel_on` is read off the
|
||||
// WARNING's rule and fires whether or not this rule is enabled, so an
|
||||
// operator who wants the cancellation and not the reassurance simply leaves
|
||||
// this one off — which, since every seeded rule ships disabled, is the
|
||||
// default.
|
||||
cooldown_seconds: 86_400,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.house.collapsed',
|
||||
name: 'House — collapsed',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('house.collapsed'),
|
||||
// No cooldown and no delay. A collapse is terminal, it happens once per
|
||||
// house, and there is nothing it could be waiting to be cancelled by.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.vendor.expiring',
|
||||
name: 'Vendor — fees due',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('vendor.expiring'),
|
||||
// A day per vendor. The mapper already fires only on the CROSSING into the
|
||||
// window, so this guards the case where a vendor is repeatedly deposited into
|
||||
// and drawn back down over the same day.
|
||||
cooldown_seconds: 86_400,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.vendor.sale',
|
||||
name: 'Vendor — a sale',
|
||||
// **Dormant on most shards, and the description has to say so.**
|
||||
// `vendor.sale` lives in `servuo-plugins/patches/` — the opt-in patch tier
|
||||
// that ADDS a `PlayerVendorSale` EventSink to core ServUO — so a shard that
|
||||
// declined the tier emits it never. That is dormant, not broken, and an
|
||||
// operator switching this on and seeing nothing deserves to know why.
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('vendor.sale'),
|
||||
// An hour per vendor. A busy shop is exactly what the digest is for; one
|
||||
// mail per longsword is how a feature earns an unsubscribe.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
|
||||
// ── Personal security — PLAIN (decision 9) ─────────────────────────────
|
||||
//
|
||||
// A security notice must be distinguishable from flavour. A failed-login mail
|
||||
// written as "a stranger sought entry to thy account" is indistinguishable in
|
||||
// register from the phishing mail it is warning about.
|
||||
{
|
||||
trigger_id: 'uo.account.login_failed',
|
||||
name: 'Account — failed game login',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
// An hour per account. A credential-stuffing run is a hundred attempts in a
|
||||
// minute and one mail is the useful outcome.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.account.unlinked',
|
||||
name: 'Account — game account unlinked',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
|
||||
// ── Personal milestone ─────────────────────────────────────────────────
|
||||
{
|
||||
trigger_id: 'uo.skill.capped',
|
||||
name: 'Skill — mastery reached',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('skill.capped'),
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.quest.complete',
|
||||
name: 'Quest — completed',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('quest.complete'),
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.character.death',
|
||||
name: 'Character — death',
|
||||
// §8.6: a killfeed some players want and most do not. Off like everything
|
||||
// else here, and its per-channel preference defaults to off as well.
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('character.death'),
|
||||
// An hour per character. Dying repeatedly is a normal afternoon in Britannia.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.character.murdered',
|
||||
name: 'Character — murdered',
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('character.murdered'),
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
|
||||
// ── Social / civic ─────────────────────────────────────────────────────
|
||||
{
|
||||
trigger_id: 'uo.guild.left',
|
||||
name: 'Guild — a member departs',
|
||||
// `members`, which resolves to the recipient set the event carries — the
|
||||
// roster resolved through `shard_account_links`. Not `authenticated`, and the
|
||||
// trigger's ceiling would refuse that anyway.
|
||||
audience: 'members',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('guild.left'),
|
||||
// An hour per guild. A guild shedding six members in an afternoon sends one.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.guild.disbanded',
|
||||
name: 'Guild — disbanded',
|
||||
audience: 'members',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('guild.disbanded'),
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.governor.appointed',
|
||||
name: 'Governor — thy appointment',
|
||||
// **The letter, and it is its own rule** (decision 10). An operator may run
|
||||
// the announcement below and leave this off, or the reverse; that is the
|
||||
// whole reason this is a second trigger rather than a second audience.
|
||||
audience: 'owner',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('governor.appointed'),
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 100,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.governor.elected',
|
||||
name: 'Governor — a city decides',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: bodies('governor.elected'),
|
||||
// An hour per CITY (the trigger's `subjectKey`): a city that flips its seat
|
||||
// twice in an hour is a shard being restarted, not two elections.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.election.opened',
|
||||
name: 'Election — the ballot opens',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: bodies('election.opened'),
|
||||
// **No delay, and that is the point of this trigger.** It carries
|
||||
// `autoPickAt` — a real deadline — and a call to action delivered after the
|
||||
// hour it names is worse than none at all.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
|
||||
// ── Come online now ────────────────────────────────────────────────────
|
||||
{
|
||||
trigger_id: 'uo.champ.started',
|
||||
name: 'Champion spawn — begun',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: bodies('champ.started'),
|
||||
// Per SPAWN, and short: the whole value is timeliness.
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.champ.boss_up',
|
||||
name: 'Champion spawn — the champion walks',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: bodies('champ.boss-up'),
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.server.up',
|
||||
name: 'Shard — came online',
|
||||
// PLAIN (decision 9): infrastructure. A crier announcing that the world
|
||||
// exists again is a joke that stops being funny during an outage.
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: GENERIC,
|
||||
// **The cooldown table's stress test** (§8.6). `uo.server.up`/`down` declare
|
||||
// NO `subjectKey`, so the cooldown subject is the recipient: an hour means a
|
||||
// shard flapping six times in a minute produces one mail, not six.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.server.down',
|
||||
name: 'Shard — went offline',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_BROADCAST,
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
|
||||
// ── Leaderboard ────────────────────────────────────────────────────────
|
||||
{
|
||||
trigger_id: 'uo.points.rank_changed',
|
||||
name: 'Leaderboard — the first place changes',
|
||||
audience: 'subscribers',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: bodies('points.rank-changed'),
|
||||
// Six hours per board. A contested top spot changes hands all evening.
|
||||
cooldown_seconds: 21_600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
|
||||
// ── Staff-facing — PLAIN (decision 9) ──────────────────────────────────
|
||||
//
|
||||
// A moderator on call at two in the morning wants a name, a rule, a location
|
||||
// and a timestamp. `notify.event` plus the structural projection gives exactly
|
||||
// that, and a scroll would bury it.
|
||||
{
|
||||
trigger_id: 'uo.page.new',
|
||||
name: 'Staff — a player opened a help page',
|
||||
audience: 'staff',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.cheat.detected',
|
||||
name: 'Staff — the cheat detector fired',
|
||||
audience: 'staff',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
// An hour per character: a detector firing every tick on one player is one
|
||||
// report, and the second report an hour later is the useful signal that it
|
||||
// has not stopped.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
|
||||
// ── Operator-facing — PLAIN, and digest-shaped by nature ───────────────
|
||||
{
|
||||
trigger_id: 'uo.audit.staff_action',
|
||||
name: 'Admin — staff actions in game',
|
||||
// `admin`, not `staff` (§8.6): a digest of what moderators did is not for
|
||||
// moderators. This is the rule the new ceiling exists for.
|
||||
audience: 'admin',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.economy.milestone',
|
||||
name: 'Admin — the economy crossed a threshold',
|
||||
audience: 'admin',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 100,
|
||||
},
|
||||
{
|
||||
trigger_id: 'uo.world.saved',
|
||||
name: 'Admin — the world saved',
|
||||
audience: 'admin',
|
||||
channels: CHANNELS_OWNER,
|
||||
template_keys: GENERIC,
|
||||
// **Six hours, and it should never be instant** (§8.6). A shard saves every
|
||||
// few minutes; this exists so an operator can notice that it STOPPED.
|
||||
cooldown_seconds: 21_600,
|
||||
max_sends_per_hour: 24,
|
||||
},
|
||||
]
|
||||
|
||||
const RULE_GROUPS = [{
|
||||
key: 'triggers-v1',
|
||||
note: 'UO notifications stay off until an operator enables one',
|
||||
rules: RULES,
|
||||
}]
|
||||
|
||||
module.exports = { TEMPLATES, RULES, RULE_GROUPS }
|
||||
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 }
|
||||
835
server/config/shardTriggers.js
Normal file
835
server/config/shardTriggers.js
Normal file
@@ -0,0 +1,835 @@
|
||||
// ── 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
|
||||
|
||||
|
||||
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
|
||||
//
|
||||
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
|
||||
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
|
||||
// court. A letter is a sentence, and a template has no conditionals by design
|
||||
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
|
||||
// leaves a hole mid-clause: "The house , in , stands in peril."
|
||||
//
|
||||
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
|
||||
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
|
||||
// declared optional. Two shapes, and each `example` shows which it is —
|
||||
//
|
||||
// • a LABEL always has a value, so it can carry a sentence's spine;
|
||||
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
|
||||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||||
//
|
||||
// They are `required: false` and therefore additive: adding one is not a
|
||||
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
|
||||
// template written before them keeps working unchanged.
|
||||
|
||||
// ── 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: '/uo/houses',
|
||||
description: 'Site-relative path to the IDOC page.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
|
||||
description: 'The decay stage as words rather than as the wire\'s enum.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// **The good outcome, and it exists because a delay without a cancel is just
|
||||
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
|
||||
// `delay_seconds: 900` so an owner who repairs the house inside the window is
|
||||
// never told it is in peril — and until Phase 11b's live walk there was
|
||||
// nothing that could cancel it: the mapper returned early on every transition
|
||||
// that was not a late stage, so a refresh reached the engine as silence. The
|
||||
// wire already carried the transition; only this declaration was missing.
|
||||
//
|
||||
// It is a real notification as well as a cancel signal (decision 11), so it
|
||||
// carries the labels a body needs rather than the serial alone.
|
||||
id: 'uo.house.refreshed',
|
||||
label: 'Your house was refreshed',
|
||||
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
|
||||
kind: 'event',
|
||||
// The SAME subject as the warning it cancels, and that is load-bearing rather
|
||||
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
|
||||
// whose subject were anything else would cancel nothing.
|
||||
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, and what the cancellation matches on.' },
|
||||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||
description: 'The house sign\'s name, when it has one.' },
|
||||
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
|
||||
description: 'The decay stage it was in before it was refreshed.' },
|
||||
{ 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.' },
|
||||
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||||
description: 'Site-relative path to the housing page.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||
{ name: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
|
||||
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/uo/market',
|
||||
description: 'Site-relative path to the market page.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
|
||||
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
|
||||
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
|
||||
description: 'The whole ledger line, or absent when the sale carried no commission.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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: '/uo/guilds/1042',
|
||||
description: 'Site-relative path to the guilds page.' },
|
||||
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// **The town's bulletin and the governor's letter are two triggers, not one**
|
||||
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
|
||||
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
|
||||
// serial and links are keyed by account — and the same reasoning was silently
|
||||
// assumed to cover this one. It does not: `city.update`'s `governor` field is
|
||||
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
|
||||
// `webId`. The new governor is addressable today, with no protocol change.
|
||||
//
|
||||
// Widening `uo.governor.elected` to two audiences was the tempting answer and
|
||||
// was refused: one trigger means one rule means ONE template, and the town's
|
||||
// announcement and the governor's letter are not the same text. Two also lets
|
||||
// an operator run the announcement and leave the letter off, or the reverse.
|
||||
id: 'uo.governor.appointed',
|
||||
label: 'You were named governor',
|
||||
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
|
||||
kind: 'event',
|
||||
// The city, not the governor: a player who somehow takes two seats in an hour
|
||||
// should get two letters, and the seat is what the event is about.
|
||||
subjectKey: 'city',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||
description: 'The city whose seat you now hold. Also the cooldown subject.' },
|
||||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Your character\'s name, as the city knows it.' },
|
||||
{ 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: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.' },
|
||||
// The same instant a person can read. A `datetime` renders as the string the
|
||||
// payload holds and core has no interpolation filters by design, so a body
|
||||
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
|
||||
// The machine value STAYS — an operator writes `is at most` conditions
|
||||
// against it — and the body uses this one.
|
||||
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
|
||||
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
|
||||
{ name: 'candidates', type: 'int', required: false, example: 3,
|
||||
description: 'How many candidates stand.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
||||
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
||||
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/uo/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: '/uo/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.' },
|
||||
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
|
||||
description: 'A label: the board\'s display name, or its system id when it has none.' },
|
||||
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
|
||||
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── 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/uo/ops',
|
||||
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: '/uo/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) },
|
||||
push: { publish: (...args) => need().push.publish(...args) },
|
||||
|
||||
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
|
||||
// event this module DECLARED has happened; the engine decides whether anyone is
|
||||
// told, on which channel, subject to which rule and preference. `inbox.push`
|
||||
// writes an in-app item with no rule at all, for the cases that are not events.
|
||||
//
|
||||
// Both are fire-and-forget and return undefined by contract — a module calls
|
||||
// them from inside a game-event handler and there is nothing it could correctly
|
||||
// do with a storage failure of core's. `inbox.push` additionally does not report
|
||||
// "the user has this switched off", because a module that could see that would
|
||||
// be a module that could enumerate people's preferences one write at a time.
|
||||
events: { emit: (...args) => need().events.emit(...args) },
|
||||
inbox: { push: (...args) => need().inbox.push(...args) },
|
||||
secretBox: {
|
||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||
decrypt: (...args) => need().secretBox.decrypt(...args),
|
||||
|
||||
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 4,
|
||||
protocol INT NOT NULL DEFAULT 5,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -727,3 +727,62 @@ ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
|
||||
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
|
||||
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
|
||||
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
|
||||
|
||||
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
|
||||
-- Two of them land as columns here; the third is a new event kind and needs none.
|
||||
--
|
||||
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
|
||||
-- at and when it was last refreshed, but nothing about WHEN the next thing
|
||||
-- happens — which is the only part a player can act on. `estimated_collapse` is
|
||||
-- nullable and stays null far more often than not, deliberately: under dynamic
|
||||
-- decay (Core.ML) ServUO draws each stage's duration at random when the stage is
|
||||
-- entered, so collapse is exactly knowable only once the house is already at
|
||||
-- IDOC. A null here means "not knowable", never "not yet read".
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
|
||||
|
||||
-- 2. vendor.listing's owner account and fee state.
|
||||
--
|
||||
-- `owner_acct` is the one that matters structurally: the table has carried
|
||||
-- `owner_name` since Protocol 3, but a character name is not an identity — only
|
||||
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
|
||||
-- an owner the site could not resolve to a user.
|
||||
--
|
||||
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
|
||||
-- the charge is compared with the funds and the vendor is destroyed when the
|
||||
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
|
||||
-- is what any surface actually wants; the parts are kept alongside it so a
|
||||
-- display can explain the number rather than only state it.
|
||||
--
|
||||
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
|
||||
-- never dismissed for fees, which is a different thing from having a long time
|
||||
-- left and must not render as one.
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
|
||||
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
|
||||
-- "vendors about to be dismissed" without scanning every shop, and the owner join
|
||||
-- that turns one into a person.
|
||||
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
|
||||
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
|
||||
|
||||
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
|
||||
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
|
||||
-- missed an earlier migration is carried the whole way; the one-shot marker is
|
||||
-- written here in the module's own fragment, because core's schema is replayed in
|
||||
-- full BEFORE any module fragment and a marker left in core would already exist
|
||||
-- when this UPDATE read it.
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
|
||||
UPDATE uo_link_config SET protocol = 5
|
||||
WHERE id = 1 AND protocol < 5
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
||||
|
||||
@@ -44,6 +44,9 @@ module.exports = function register(ctx, api) {
|
||||
const usersShardExtension = require('./router/admin/usersShard.router')
|
||||
|
||||
const shardStreams = require('./config/shardStreams')
|
||||
const shardTriggers = require('./config/shardTriggers')
|
||||
const shardAudiences = require('./config/shardAudiences')
|
||||
const engagementSeeds = require('./config/engagementSeeds')
|
||||
const townCrierLeg = require('./utils/shardAnnounce')
|
||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||
const guildCommand = require('./commands/guild.command')
|
||||
@@ -88,6 +91,53 @@ module.exports = function register(ctx, api) {
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
|
||||
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
|
||||
// are PAYLOAD contracts: what a rule may fire on, what a template may
|
||||
// interpolate, and — the part that is a security boundary — the widest audience
|
||||
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
|
||||
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
|
||||
// and core refuses a rule that widens either.
|
||||
//
|
||||
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
|
||||
// registration and the one above are two facets of one space and core enforces
|
||||
// that an id has exactly one owner across both. None of the ids below reuses a
|
||||
// stream id: the stream catalog keeps its seven grandfathered names and these
|
||||
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
|
||||
// in-app preferences and no push toggle, which is correct — there is nothing to
|
||||
// push it to, and the shipped Android client's catalog is unchanged.
|
||||
api.registerEventTriggers(shardTriggers.TRIGGERS)
|
||||
|
||||
// Audiences are named sets of PEOPLE an operator composes rules and segments
|
||||
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
|
||||
// composition takes the narrowest ceiling it contains, never the widest.
|
||||
//
|
||||
// Registration is a claim; nothing resolves until the engine asks, which is
|
||||
// after `onBoot` — and it must be, because every resolver reads the database
|
||||
// and registration must not (§2.2 rule 1).
|
||||
api.registerAudiences(shardAudiences.AUDIENCES)
|
||||
|
||||
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
|
||||
// Phase 11b): sixteen in-universe message bodies on two channels each, and
|
||||
// twenty-five rules — every one of them `enabled = 0`, which the registry
|
||||
// enforces rather than trusts.
|
||||
//
|
||||
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
|
||||
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
|
||||
// that is on still passes the ceiling, the per-user preference, the suppression
|
||||
// list and the verification gate before anything is sent — all of them core's.
|
||||
//
|
||||
// The nine security and operational triggers point at core's generic bodies
|
||||
// (decision 9). A cheat report should read like a cheat report.
|
||||
//
|
||||
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
|
||||
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
|
||||
// fresh installs ONLY. A future trigger wants its own group key.
|
||||
api.registerEngagementSeeds({
|
||||
templates: engagementSeeds.TEMPLATES,
|
||||
ruleGroups: engagementSeeds.RULE_GROUPS,
|
||||
})
|
||||
|
||||
|
||||
// Teams: a UO guild is a Team, and this module is the authoritative source of
|
||||
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
|
||||
// everything about what a guild IS stays here.
|
||||
@@ -114,5 +164,7 @@ module.exports = function register(ctx, api) {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||
streams: shardStreams.STREAMS.length,
|
||||
triggers: shardTriggers.TRIGGERS.length,
|
||||
audiences: shardAudiences.AUDIENCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,4 +39,56 @@ const remove = (account, userId) =>
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
|
||||
// A bound on every "resolve a set of people" read below. It mirrors core's own
|
||||
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
|
||||
// cannot reach into core's models, and the number this file has to respect is
|
||||
// "no more ids than core will accept" whatever core calls it.
|
||||
const MAX_AUDIENCE = 5000
|
||||
|
||||
// **Website user ids for a set of game accounts.** The bulk form of
|
||||
// `getByAccount`, and the one the engagement mapper needs: a guild event's
|
||||
// audience is its members, and turning a roster into a set of people is one join
|
||||
// rather than one query per member (Phase 11).
|
||||
//
|
||||
// DISTINCT because two characters on one guild roster can share an account, and
|
||||
// the caller wants people rather than characters.
|
||||
async function userIdsForAccounts(accounts) {
|
||||
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
|
||||
if (!wanted.length) return []
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const marks = capped.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
|
||||
capped,
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
// **Every website user with a linked game account** — the `uo.linked.accounts`
|
||||
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
|
||||
// one a `not` composes against ("everyone who has NOT linked").
|
||||
//
|
||||
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
|
||||
// never sees an address, a channel or a template, and core maps ids to addresses
|
||||
// on its own side after preferences, suppression and the verification gate.
|
||||
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
|
||||
const rows = await query(
|
||||
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
|
||||
[limit],
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsert,
|
||||
getByAccount,
|
||||
listByUser,
|
||||
isOwnedBy,
|
||||
remove,
|
||||
removeByAccount,
|
||||
userIdsForAccounts,
|
||||
allLinkedUserIds,
|
||||
MAX_AUDIENCE,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
// The bulk resolvers the engagement audiences and the guild mapper need
|
||||
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
|
||||
// put here, and a module's audience resolver returning ids and nothing else is
|
||||
// the contract (§5.1a rule 2).
|
||||
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
|
||||
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
|
||||
|
||||
module.exports = {
|
||||
link,
|
||||
listForUser,
|
||||
ownsAccount,
|
||||
getByAccount,
|
||||
unlink,
|
||||
removeByAccount,
|
||||
userIdsForAccounts,
|
||||
allLinkedUserIds,
|
||||
}
|
||||
|
||||
@@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) {
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO shard_vendors
|
||||
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
(serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t,
|
||||
fees_exempt, charge_per_period, funds, pay_interval_sec, next_pay_at,
|
||||
periods_remaining, dismissal_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
||||
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||
owner_name = VALUES(owner_name), owner_acct = VALUES(owner_acct),
|
||||
map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
||||
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
||||
truncated = VALUES(truncated), t = VALUES(t),
|
||||
-- Protocol 5. Written back unconditionally, INCLUDING when they are null:
|
||||
-- a shard downgraded to a pre-v5 overlay stops sending the fees object, and
|
||||
-- leaving the last v5 values in place would leave a dismissal date standing
|
||||
-- that nothing is maintaining any more. A stale deadline is worse than none.
|
||||
fees_exempt = VALUES(fees_exempt), charge_per_period = VALUES(charge_per_period),
|
||||
funds = VALUES(funds), pay_interval_sec = VALUES(pay_interval_sec),
|
||||
next_pay_at = VALUES(next_pay_at), periods_remaining = VALUES(periods_remaining),
|
||||
dismissal_at = VALUES(dismissal_at),
|
||||
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
||||
-- MariaDB does not fire that when every column is written back
|
||||
-- unchanged, and a shop that is re-published identically is still
|
||||
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
|
||||
vendor.shopName ?? null,
|
||||
vendor.ownerSerial ?? null,
|
||||
vendor.ownerName ?? null,
|
||||
vendor.ownerAcct ?? null,
|
||||
vendor.map ?? null,
|
||||
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||
@@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) {
|
||||
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||
vendor.truncated ? 1 : 0,
|
||||
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||
vendor.feesExempt ? 1 : 0,
|
||||
Number.isFinite(vendor.chargePerPeriod) ? vendor.chargePerPeriod : null,
|
||||
Number.isFinite(vendor.funds) ? vendor.funds : null,
|
||||
Number.isFinite(vendor.payIntervalSec) ? vendor.payIntervalSec : null,
|
||||
vendor.nextPayAt ?? null,
|
||||
Number.isFinite(vendor.periodsRemaining) ? vendor.periodsRemaining : null,
|
||||
vendor.dismissalAt ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const MAX_OWNER = 64
|
||||
const MAX_MAP = 40
|
||||
const MAX_REGION = 80
|
||||
const MAX_SERIAL = 20
|
||||
const MAX_ACCT = 120
|
||||
|
||||
const clip = (value, max) => {
|
||||
if (value == null) return null
|
||||
@@ -43,6 +44,42 @@ const int = (value, fallback = 0) => {
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
// A wire timestamp -> a Date the DB layer can bind, or null. The shard emits ISO-8601
|
||||
// (`DateTime.ToString("o")`); anything else is a plugin we do not recognise and is
|
||||
// dropped rather than stored as an Invalid Date, which MariaDB rejects in strict mode
|
||||
// and which would fail the whole vendor over one bad field.
|
||||
const when = (value) => {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// Protocol 5. The vendor's fee state, normalised out of the frame's `fees` object.
|
||||
//
|
||||
// Two things this deliberately does NOT do. It does not recompute `dismissalAt` from
|
||||
// the parts -- the shard resolved it against ServUO's own two vendor systems (the
|
||||
// charge, the funds and the interval all differ between them) and re-deriving it here
|
||||
// would be a second implementation of a rule that lives in PlayerVendor.PayTimer. And
|
||||
// it does not treat a missing `fees` object as zero: a pre-v5 overlay simply omits it,
|
||||
// and nulls are how a v5 website says "this shard has not told me" rather than
|
||||
// "this vendor is broke", which is the difference between silence and a false alarm.
|
||||
const fees = (f) => {
|
||||
if (!f || typeof f !== 'object') return { feesExempt: false, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
|
||||
// A commission vendor has no pay timer and is never dismissed for fees. Reporting it
|
||||
// as exempt with no schedule is not the same as reporting a very long one, and a
|
||||
// surface that renders "never" must be able to tell them apart.
|
||||
if (f.exempt === true) return { feesExempt: true, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
|
||||
return {
|
||||
feesExempt: false,
|
||||
chargePerPeriod: Number.isFinite(f.chargePerPeriod) ? Math.trunc(f.chargePerPeriod) : null,
|
||||
funds: Number.isFinite(f.funds) ? Math.trunc(f.funds) : null,
|
||||
payIntervalSec: Number.isFinite(f.payIntervalSec) ? Math.trunc(f.payIntervalSec) : null,
|
||||
nextPayAt: when(f.nextPayAt),
|
||||
periodsRemaining: Number.isFinite(f.periodsRemaining) ? Math.trunc(f.periodsRemaining) : null,
|
||||
dismissalAt: when(f.dismissalAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -64,6 +101,10 @@ function flattenFrame(ev) {
|
||||
shopName: clip(ev.shopName, MAX_SHOP),
|
||||
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||
// Protocol 5. The character name has been here since v3, but only the game
|
||||
// ACCOUNT joins to shard_account_links -- so this is the field that makes a
|
||||
// vendor row resolvable to a person at all.
|
||||
ownerAcct: clip(ev.ownerAcct, MAX_ACCT),
|
||||
map: clip(loc.map, MAX_MAP),
|
||||
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
||||
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
||||
@@ -76,6 +117,7 @@ function flattenFrame(ev) {
|
||||
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||
truncated: ev.truncated === true,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
...fees(ev.fees),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,12 @@ async function latestEconomy() {
|
||||
|
||||
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||
const HOUSE_COLS =
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' +
|
||||
// Protocol 5's decay schedule. Added to the BASE column list rather than to
|
||||
// HOUSE_REG_COLS because it arrives on house.decay, so a decay-only row -- one the
|
||||
// registry sweep has never seen -- carries it too, and the public IDOC page reads
|
||||
// exactly those rows.
|
||||
', next_stage, estimated_collapse, decay_period_sec, dynamic_decay'
|
||||
|
||||
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
|
||||
|
||||
@@ -215,6 +220,26 @@ const listGuildMembers = (guildId) =>
|
||||
guildId,
|
||||
])
|
||||
|
||||
|
||||
// **The game accounts on one guild's roster** — the input to
|
||||
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
|
||||
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
|
||||
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
|
||||
// the authoritative map. A mirror that has drifted would mail the wrong person,
|
||||
// and a mirror that is behind would mail nobody, so the query that decides who
|
||||
// is told reads the table whose job that is.
|
||||
const listGuildMemberAccounts = (guildId) =>
|
||||
query(
|
||||
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
|
||||
[guildId],
|
||||
)
|
||||
|
||||
// The accounts of every sitting governor — the `uo.governors` audience.
|
||||
// `governor_acct` is NULL on a city with no governor and on one whose governor's
|
||||
// mobile has no account, and both are simply nobody.
|
||||
const listGovernorAccounts = () =>
|
||||
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
|
||||
|
||||
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||
@@ -391,6 +416,8 @@ module.exports = {
|
||||
removeGuildMember,
|
||||
clearAllGuildMembers,
|
||||
listGuildMembers,
|
||||
listGuildMemberAccounts,
|
||||
listGovernorAccounts,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
|
||||
@@ -124,10 +124,39 @@ async function upsertHouse(data) {
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||
// Protocol 5. `ownerName` is written back only when the frame carries one, and
|
||||
// that asymmetry is deliberate: house.update also writes this column, from a
|
||||
// different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at
|
||||
// all. Coalescing to null here would let every decay transition ERASE a name the
|
||||
// registry had already resolved.
|
||||
...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}),
|
||||
...decayScheduleFields(data.schedule),
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
// Protocol 5's `schedule` object, flattened into its columns.
|
||||
//
|
||||
// Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls.
|
||||
// A schedule is a claim about the future and it goes stale on its own: if a shard is
|
||||
// rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops
|
||||
// being knowable, the right stored value is "nothing" rather than the last thing we
|
||||
// were told. A dated promise nobody is maintaining is worse than no promise.
|
||||
function decayScheduleFields(schedule) {
|
||||
const s = schedule && typeof schedule === 'object' ? schedule : {}
|
||||
const when = (v) => {
|
||||
if (!v) return null
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
return {
|
||||
next_stage: when(s.nextStage),
|
||||
estimated_collapse: when(s.estimatedCollapse),
|
||||
decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null,
|
||||
dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function shapeHouse(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
@@ -149,6 +178,16 @@ function shapeHouse(r) {
|
||||
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
// Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`:
|
||||
// the visibility projection matches literal JSON keys, so the stored read model
|
||||
// and the live wire frame have to spell this the same way or the one admin rule
|
||||
// covers only one of the two paths.
|
||||
schedule: {
|
||||
dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay),
|
||||
nextStage: r.next_stage,
|
||||
decayPeriodSec: r.decay_period_sec,
|
||||
estimatedCollapse: r.estimated_collapse,
|
||||
},
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
@@ -423,6 +462,19 @@ async function listGuildMembers(guildId) {
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
|
||||
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
|
||||
// through `shardVisibility`, which strips `acct` for anyone below admin, so
|
||||
// building an audience out of it would either leak the projection's job into
|
||||
// this one or silently resolve to nobody depending on who asked. These two go to
|
||||
// the database for exactly the column they need and pass nothing else on.
|
||||
const listGuildMemberAccounts = async (guildId) =>
|
||||
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
|
||||
|
||||
const listGovernorAccounts = async () =>
|
||||
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
@@ -703,6 +755,8 @@ module.exports = {
|
||||
upsertGuildRoster,
|
||||
removeGuildMember,
|
||||
listGuildMembers,
|
||||
listGuildMemberAccounts,
|
||||
listGovernorAccounts,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
|
||||
@@ -11,13 +11,16 @@ const { secretBox } = require('../../core')
|
||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||
//
|
||||
// This says 4 because this build handles protocol 4's frames: `guild.roster` and
|
||||
// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after
|
||||
// that, which is the bug this constant is now the fix for — a FRESH install pinned
|
||||
// 3, the sidecar answered `409 protocol version mismatch` to every REST call, and a
|
||||
// new deployment read nothing from its shard until an admin edited the number by
|
||||
// hand in Admin → Shard. See the matching cutover in db/schema.sql.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4
|
||||
// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
|
||||
// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
|
||||
//
|
||||
// It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
|
||||
// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
|
||||
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
||||
// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
|
||||
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
|
||||
// matching cutover in db/schema.sql.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
@@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||
// DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant
|
||||
// and schema.sql's two declarations of the same number AGREE, rather than asserting a
|
||||
// hardcoded version at each site -- which is what let them drift apart before.
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }
|
||||
|
||||
@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
|
||||
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
push: { publish: spy(Promise.resolve()) },
|
||||
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
||||
// contract — a module gets no delivery answer back, deliberately — so the
|
||||
// spies return undefined rather than a promise, which is what core does.
|
||||
events: { emit: spy(undefined) },
|
||||
inbox: { push: spy(undefined) },
|
||||
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
@@ -97,6 +102,8 @@ function fakeApi() {
|
||||
legs: [],
|
||||
teamProvider: null,
|
||||
slashCommands: [],
|
||||
triggers: null,
|
||||
audiences: null,
|
||||
hooks: {},
|
||||
}
|
||||
const called = new Set()
|
||||
@@ -117,6 +124,16 @@ function fakeApi() {
|
||||
// takes it: a second call is a module changing its mind halfway through
|
||||
// register(), which core rejects.
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
|
||||
// the reason above: core stages a registrant's whole batch and applies it as
|
||||
// one, so a second call is a module changing its mind mid-register().
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
|
||||
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
|
||||
// and merging two calls would make "which group is this rule in" — the
|
||||
// question the one-shot seed guard answers — unanswerable.
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
242
server/test/engagementSeeds.test.js
Normal file
242
server/test/engagementSeeds.test.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
|
||||
//
|
||||
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
|
||||
// file proves the content shipped alongside them is coherent — which is a
|
||||
// different failure mode and a quieter one: a rule pointing at a template key
|
||||
// that does not exist, or a body built around a variable nothing supplies, is
|
||||
// invisible until somebody enables the rule and a person does not get a mail.
|
||||
//
|
||||
// The three properties worth asserting, none of which a hand run would catch:
|
||||
//
|
||||
// 1. **Every rule names a trigger this module declares, and a template that
|
||||
// exists** — its own or core's nine generic keys.
|
||||
// 2. **Every LABEL a body builds a sentence around is supplied on every path
|
||||
// that emits its trigger.** This is the one that earns its keep. The
|
||||
// fragments are declared `required: false` so a missing one can never
|
||||
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
|
||||
// and that leaves nothing at runtime to notice a mapper that forgot one.
|
||||
// This test is what notices.
|
||||
// 3. **The plain nine are plain** (decision 9). A security notice drifting
|
||||
// into the in-universe register is exactly the change nobody would think to
|
||||
// review, and it is the one with a real cost attached.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const engagement = require('../utils/shardEngagement')
|
||||
const seeds = require('../config/engagementSeeds')
|
||||
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
||||
|
||||
let tracker
|
||||
beforeEach(() => { tracker = engagement.createTracker() })
|
||||
|
||||
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
|
||||
|
||||
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
|
||||
// property 1). Spelled out rather than imported: this module cannot require core,
|
||||
// and a key disappearing from core is exactly the breakage worth failing on.
|
||||
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
||||
|
||||
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
|
||||
const PLAIN = new Set([
|
||||
'uo.account.login_failed', 'uo.account.unlinked',
|
||||
'uo.server.up', 'uo.server.down',
|
||||
'uo.page.new', 'uo.cheat.detected',
|
||||
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
|
||||
])
|
||||
|
||||
// ── The shape of the set ───────────────────────────────────────────────────
|
||||
|
||||
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
|
||||
const ruled = seeds.RULES.map((r) => r.trigger_id)
|
||||
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
|
||||
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
|
||||
})
|
||||
|
||||
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
|
||||
for (const r of seeds.RULES) {
|
||||
// `enabled` is not set here at all — the registry forces 0 — so the
|
||||
// assertion is that nobody added it. Q3's invariant, at the source.
|
||||
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
|
||||
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
|
||||
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every template key a rule names exists — its own or core\'s', () => {
|
||||
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||
for (const r of seeds.RULES) {
|
||||
for (const [channel, key] of Object.entries(r.template_keys)) {
|
||||
assert.ok(
|
||||
own.has(key) || CORE_KEYS.has(key),
|
||||
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
|
||||
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||
let bespoke = 0
|
||||
for (const r of seeds.RULES) {
|
||||
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
|
||||
if (PLAIN.has(r.trigger_id)) {
|
||||
// **Decision 9, as a check.** A security notice written as a letter is
|
||||
// indistinguishable in register from the phishing mail it warns about.
|
||||
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
|
||||
continue
|
||||
}
|
||||
bespoke += 1
|
||||
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
|
||||
// Both channels in the same voice: one rule fires on both at once, and a
|
||||
// player who reads the inbox item and then the mail must not meet two
|
||||
// different narrators.
|
||||
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
|
||||
// The DIGEST stays core's. A day of events rolled into a list is not a
|
||||
// letter from anybody.
|
||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||
}
|
||||
assert.equal(bespoke, 17)
|
||||
assert.equal(seeds.TEMPLATES.length, 34)
|
||||
})
|
||||
|
||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
|
||||
// is a genuinely confusing pair and the reason this is asserted rather than
|
||||
// remembered. Caught at registration too, as a boot failure.
|
||||
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
||||
for (const t of seeds.TEMPLATES) {
|
||||
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
|
||||
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
|
||||
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an email body has a subject and an in-app body has none', () => {
|
||||
for (const t of seeds.TEMPLATES) {
|
||||
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
|
||||
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
|
||||
}
|
||||
})
|
||||
|
||||
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
|
||||
// One prebuilt image mails as any shard. An in-universe body is UO-specific
|
||||
// and must still be shard-agnostic.
|
||||
const json = JSON.stringify(seeds.TEMPLATES)
|
||||
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
|
||||
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The property the render sweep needed ───────────────────────────────────
|
||||
|
||||
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
|
||||
// trailing ones that may legitimately be empty. A frame that exercises each.
|
||||
const LABELLED = [
|
||||
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
|
||||
['uo.house.collapsed', ['houseLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
|
||||
['uo.vendor.sale', ['shopLabel', 'itemLine'],
|
||||
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
|
||||
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
|
||||
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
|
||||
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
|
||||
// has a hole in it without one. It is `required: false` like the others and
|
||||
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
|
||||
// all unless the frame carried `autoPickAt`.
|
||||
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
|
||||
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
|
||||
['uo.house.refreshed', ['houseLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
|
||||
]
|
||||
|
||||
test('every label a body builds a sentence around is supplied by the mapper', () => {
|
||||
for (const [triggerId, labels, frame] of LABELLED) {
|
||||
// A first frame is never a transition, so the upsert kinds need a prior one.
|
||||
engagement.mapShardEvent(
|
||||
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
|
||||
tracker,
|
||||
)
|
||||
const targets = engagement.mapShardEvent(frame, tracker)
|
||||
const target = targets.find((t) => t.triggerId === triggerId)
|
||||
assert.ok(target, `${triggerId} fired`)
|
||||
for (const label of labels) {
|
||||
assert.ok(
|
||||
target.data[label] !== undefined && target.data[label] !== '',
|
||||
`${triggerId} supplies ${label} — a body builds a sentence around it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a label is supplied even when every optional field is absent', () => {
|
||||
// The case the render sweep modelled: a v4 overlay, a house with no name and
|
||||
// no region. `houseLabel` falls back to the seal number, which is worse prose
|
||||
// and better than "Be it known that , recorded to thy name".
|
||||
const target = engagement.mapShardEvent(
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.match(target.data.houseLabel, /0x40012345/)
|
||||
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
|
||||
// The detail line names only what the frame carried — "Recorded at: ." is the
|
||||
// shape this avoids. The stage is always there, so the line is too; a house
|
||||
// with no coordinates simply does not get the "Recorded at" half.
|
||||
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
|
||||
})
|
||||
|
||||
test('a detail line names only the parts the frame actually carried', () => {
|
||||
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
|
||||
const at = new Date(Date.now() + 3600_000).toISOString()
|
||||
const target = engagement.mapShardEvent(
|
||||
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(target.triggerId, 'uo.vendor.expiring')
|
||||
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
|
||||
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
|
||||
})
|
||||
|
||||
// ── Trailing fragments ─────────────────────────────────────────────────────
|
||||
|
||||
test('a trailing fragment leads with its own space, or is absent entirely', () => {
|
||||
// `{{slainBy}}.` must close as "has fallen." with no fragment and
|
||||
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
|
||||
// its leading space produces "has fallenat the hands of" and nothing would
|
||||
// notice.
|
||||
const withKiller = engagement.mapShardEvent(
|
||||
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
|
||||
|
||||
const without = engagement.mapShardEvent(
|
||||
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(without.data.slainBy, undefined)
|
||||
})
|
||||
|
||||
test('every declared fragment carries an example that shows its own shape', () => {
|
||||
// The `example` is what the template editor previews and test-sends with, so a
|
||||
// trailing fragment whose example omits the leading space teaches an author the
|
||||
// wrong thing about where to put one.
|
||||
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
||||
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── The group key ──────────────────────────────────────────────────────────
|
||||
|
||||
test('one rule group, and appending to it later would reach fresh installs only', () => {
|
||||
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
||||
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
||||
// rule has to edit a test whose name says what appending costs.
|
||||
assert.equal(seeds.RULE_GROUPS.length, 1)
|
||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||
})
|
||||
@@ -144,12 +144,23 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
|
||||
// sidecar, which 409s every REST call — an install that reads nothing from its
|
||||
// shard, with the cause only in the log. These tests are the guard.
|
||||
|
||||
// The protocol this build speaks, read from the model rather than written here.
|
||||
//
|
||||
// Hardcoding the number in this test is what the protocol-4 bug looked like from the
|
||||
// other side: the emitters moved, one declaration site did not, and every site agreed
|
||||
// with itself. Reading DEFAULT_PROTOCOL makes the assertion "the three declarations
|
||||
// AGREE" rather than "they all say 4", so a bump that misses one of them fails here
|
||||
// instead of on an operator's install.
|
||||
const { DEFAULT_PROTOCOL } = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
|
||||
test('the column default pins the protocol this build speaks', () => {
|
||||
assert.ok(Number.isInteger(DEFAULT_PROTOCOL) && DEFAULT_PROTOCOL > 0, 'no protocol pin exported')
|
||||
|
||||
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
|
||||
assert.ok(create, 'uo_link_config is gone')
|
||||
assert.match(
|
||||
create,
|
||||
/protocol\s+INT\s+NOT NULL DEFAULT 4/i,
|
||||
new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
|
||||
'the CREATE TABLE default must name the protocol this build speaks',
|
||||
)
|
||||
|
||||
@@ -159,7 +170,34 @@ test('the column default pins the protocol this build speaks', () => {
|
||||
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
|
||||
)
|
||||
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
|
||||
assert.match(modifies[modifies.length - 1], /DEFAULT 4/i)
|
||||
assert.match(
|
||||
modifies[modifies.length - 1],
|
||||
new RegExp('DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
|
||||
)
|
||||
})
|
||||
|
||||
// The one-shot migration for the CURRENT protocol, whatever it is. Same argument as
|
||||
// above: these three assertions used to be written once per version by hand, so the
|
||||
// version that mattered — the newest — was the one with no test until someone
|
||||
// remembered to copy the block.
|
||||
test('the current protocol has a one-shot migration, correctly ordered and guarded', () => {
|
||||
const marker = `uo_link_protocol_${DEFAULT_PROTOCOL}_migrated`
|
||||
|
||||
const update = statements.findIndex(
|
||||
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes(marker),
|
||||
)
|
||||
const insert = statements.findIndex((s) => /^INSERT/i.test(s) && s.includes(`'${marker}'`))
|
||||
|
||||
assert.ok(update >= 0, `no migration to protocol ${DEFAULT_PROTOCOL}`)
|
||||
assert.ok(insert >= 0, `no one-shot marker for protocol ${DEFAULT_PROTOCOL}`)
|
||||
assert.ok(insert > update, 'the marker is written before the UPDATE reads it')
|
||||
|
||||
// `protocol < N`, never `= N-1`: an install that missed an earlier migration has to
|
||||
// be carried the whole way rather than one step.
|
||||
assert.match(
|
||||
statements[update],
|
||||
new RegExp('protocol *< *' + DEFAULT_PROTOCOL + '(?![0-9])'),
|
||||
)
|
||||
})
|
||||
|
||||
test('the protocol-4 marker is written AFTER the update that reads it', () => {
|
||||
|
||||
645
server/test/shardEngagement.test.js
Normal file
645
server/test/shardEngagement.test.js
Normal file
@@ -0,0 +1,645 @@
|
||||
// ── 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')
|
||||
const { PATHS } = require('../config/clientPaths')
|
||||
|
||||
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, 26)
|
||||
// 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`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a url example names a route this module actually mounts', () => {
|
||||
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
|
||||
// `mounts` — and the client router prefixes a module's routes with its **ID**
|
||||
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
|
||||
// over: the example is what the template editor previews and test-sends with,
|
||||
// and `clientPaths.js` is now the single place both it and the bodies read.
|
||||
const known = new Set(Object.values(PATHS))
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => x.type === 'url')) {
|
||||
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
|
||||
const parent = v.example.replace(/\/[^/]+$/, '')
|
||||
assert.ok(
|
||||
known.has(v.example) || known.has(parent),
|
||||
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every url variable a body can interpolate is actually SUPPLIED', () => {
|
||||
// The defect this exists for is invisible in the source and invisible in a
|
||||
// fixture: a declared-but-never-populated optional interpolates to the empty
|
||||
// string, so the letter renders perfectly and its call-to-action button has no
|
||||
// href. Nine of the sixteen in-universe bodies shipped that way.
|
||||
//
|
||||
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
|
||||
// added is covered the day it is declared.
|
||||
const frames = {
|
||||
'uo.house.idoc_warning': DECAY,
|
||||
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
|
||||
'uo.vendor.expiring': listing(FEES(20)),
|
||||
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
|
||||
// Two frames each: an upsert kind is never a transition on FIRST sight, so
|
||||
// the tracker has to see a baseline before the change means anything.
|
||||
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
||||
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
||||
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
||||
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
||||
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
||||
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
||||
'uo.server.down': { kind: 'server.shutdown' },
|
||||
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
||||
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
|
||||
}
|
||||
|
||||
for (const t of TRIGGERS) {
|
||||
const urls = t.variables.filter((v) => v.type === 'url')
|
||||
if (!urls.length) continue
|
||||
const frame = frames[t.id]
|
||||
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
|
||||
|
||||
const fresh = engagement.createTracker()
|
||||
let target = null
|
||||
for (const f of Array.isArray(frame) ? frame : [frame]) {
|
||||
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
|
||||
if (hit) target = hit
|
||||
}
|
||||
assert.ok(target, `${t.id} did not fire for its frame`)
|
||||
|
||||
for (const v of urls) {
|
||||
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
|
||||
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-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)')
|
||||
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
|
||||
// not news, and mailing it would make the warning worthless.
|
||||
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
|
||||
})
|
||||
|
||||
test('a refresh is its own trigger, and it is what cancels the warning', () => {
|
||||
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
|
||||
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
|
||||
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
|
||||
// the decay sweep has always emitted this transition.
|
||||
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
|
||||
assert.equal(t.triggerId, 'uo.house.refreshed')
|
||||
assert.equal(t.ownerAccount, 'seed_002')
|
||||
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
|
||||
// (rule, subject_key), so a different one would cancel nothing.
|
||||
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
|
||||
assert.equal(t.data.previousStage, 'Greatly')
|
||||
// A TRAILING fragment: its own leading space, and empty rather than reading
|
||||
// "It stood in decay." when the previous stage has no word of its own.
|
||||
assert.equal(t.data.fromLine, ' It stood greatly worn.')
|
||||
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
|
||||
})
|
||||
|
||||
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.
|
||||
// 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
|
||||
// number the answer is 20 or 19 depending on whether a millisecond has passed
|
||||
// since the fixture was built, and this assertion was flaking on exactly that.
|
||||
const first = one(listing(FEES(20.5)))
|
||||
assert.equal(first.triggerId, 'uo.vendor.expiring')
|
||||
assert.equal(first.ownerAccount, 'darrow_acct')
|
||||
assert.equal(first.data.hoursRemaining, 20)
|
||||
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 ELECTED governor with a linked account also gets a letter', () => {
|
||||
// Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a
|
||||
// person because `top[]` names a serial — and the same reasoning was silently
|
||||
// assumed to cover the governor. It does not: `BridgeJson.Actor()` writes
|
||||
// `acct` on every actor object, so the winner is addressable with no protocol
|
||||
// change. This test is the record of that, and of the decision that the
|
||||
// announcement and the letter are TWO triggers.
|
||||
map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } }))
|
||||
const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } }))
|
||||
assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed'])
|
||||
|
||||
const letter = out[1]
|
||||
assert.equal(letter.ownerAccount, 'darrow')
|
||||
assert.equal(letter.data.city, 'Britain')
|
||||
assert.equal(letter.data.governorName, 'Darrow')
|
||||
// The bulletin carries no owner — it is the town's, not the governor's.
|
||||
assert.equal(out[0].ownerAccount, undefined)
|
||||
})
|
||||
|
||||
test('an UNLINKED governor still gets the town its announcement', () => {
|
||||
// Nobody to write to is an ordinary outcome, not an error — most game accounts
|
||||
// on most shards have never been linked — and it must not cost the city its
|
||||
// proclamation.
|
||||
map(city({ governor: { serial: '0x1', name: 'Mireille' } }))
|
||||
assert.deepEqual(
|
||||
ids(city({ governor: { serial: '0x2', name: 'Darrow' } })),
|
||||
['uo.governor.elected'],
|
||||
)
|
||||
})
|
||||
|
||||
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 ─────────────────────────
|
||||
|
||||
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
|
||||
// one, taken FROM that model rather than written out here: the model's `toSafe`
|
||||
// camel-cases the row, and a hand-written fake using the column names is a fake
|
||||
// that will agree with a resolver reading the column names. Stubbing the db
|
||||
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
|
||||
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
|
||||
function linkRow(account, userId) {
|
||||
const realGet = shardLinksDb.getByAccount
|
||||
shardLinksDb.getByAccount = async () => ({
|
||||
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
|
||||
})
|
||||
try {
|
||||
return shardLinksModel.getByAccount(account)
|
||||
} finally {
|
||||
shardLinksDb.getByAccount = realGet
|
||||
}
|
||||
}
|
||||
|
||||
function deps(over = {}) {
|
||||
const emitted = []
|
||||
return {
|
||||
emitted,
|
||||
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
||||
tracker,
|
||||
shardLinks: {
|
||||
// Shaped by the REAL model's `toSafe`, not by the column names. A fake that
|
||||
// returns `user_id` agrees with a resolver that reads `user_id`, and the
|
||||
// pair passes while every owner-audienced trigger reaches nobody on a live
|
||||
// shard — which is exactly what happened. `linkRow` below is the guard.
|
||||
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 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, [])
|
||||
})
|
||||
@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
|
||||
const found = await clilocs.resolveMany([1023721])
|
||||
assert.equal(found.get(1023721), 'quarter staff')
|
||||
})
|
||||
|
||||
// ── Protocol 5: owner account and fee state ────────────────────────────────
|
||||
|
||||
const V5_FEES = {
|
||||
exempt: false,
|
||||
newVendorSystem: true,
|
||||
chargePerPeriod: 148,
|
||||
funds: 2960,
|
||||
holdGold: 2960,
|
||||
bankAccount: 0,
|
||||
payIntervalSec: 86400,
|
||||
nextPayAt: '2026-09-01T00:00:00.000Z',
|
||||
periodsRemaining: 20,
|
||||
dismissalAt: '2026-09-21T00:00:00.000Z',
|
||||
}
|
||||
|
||||
test('flattenFrame lifts ownerAcct, the field that makes a shop resolvable to a person', () => {
|
||||
// ownerName has been on the frame since v3, but a character name joins to nothing:
|
||||
// shard_account_links is keyed by the game ACCOUNT.
|
||||
const row = market.flattenFrame({ ...FRAME, ownerAcct: 'darrow_acct', fees: V5_FEES })
|
||||
assert.equal(row.ownerAcct, 'darrow_acct')
|
||||
assert.equal(row.ownerName, 'Darrow', 'the character name is still carried too')
|
||||
})
|
||||
|
||||
test('flattenFrame normalises the fee block, dates included', () => {
|
||||
const row = market.flattenFrame({ ...FRAME, fees: V5_FEES })
|
||||
assert.equal(row.feesExempt, false)
|
||||
assert.equal(row.chargePerPeriod, 148)
|
||||
assert.equal(row.funds, 2960)
|
||||
assert.equal(row.payIntervalSec, 86400)
|
||||
assert.equal(row.periodsRemaining, 20)
|
||||
assert.ok(row.nextPayAt instanceof Date)
|
||||
assert.equal(row.dismissalAt.toISOString(), '2026-09-21T00:00:00.000Z')
|
||||
})
|
||||
|
||||
// The shard resolved dismissalAt against ServUO's two vendor systems, whose charge,
|
||||
// funds and pay interval all differ. Re-deriving it here would be a second
|
||||
// implementation of a rule that lives in PlayerVendor.PayTimer.
|
||||
test('flattenFrame trusts the shard dismissal date instead of recomputing it', () => {
|
||||
const row = market.flattenFrame({
|
||||
...FRAME,
|
||||
fees: { ...V5_FEES, dismissalAt: '2026-12-25T00:00:00.000Z' },
|
||||
})
|
||||
assert.equal(row.dismissalAt.toISOString(), '2026-12-25T00:00:00.000Z')
|
||||
})
|
||||
|
||||
// A commission vendor has no pay timer and is never dismissed for fees. That is a
|
||||
// different thing from having a long time left, and a surface rendering "never" has
|
||||
// to be able to tell them apart.
|
||||
test('an exempt vendor reports exempt with no schedule at all', () => {
|
||||
const row = market.flattenFrame({ ...FRAME, fees: { exempt: true } })
|
||||
assert.equal(row.feesExempt, true)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.periodsRemaining, null)
|
||||
assert.equal(row.chargePerPeriod, null)
|
||||
})
|
||||
|
||||
// A pre-v5 overlay omits `fees` entirely, and a shard can be rolled back to one.
|
||||
// Nulls have to mean "this shard has not told me", never "this vendor is broke" —
|
||||
// the difference between silence and a false alarm in a rule that mails an owner.
|
||||
test('a pre-v5 frame yields nulls, not zeroes', () => {
|
||||
const row = market.flattenFrame(FRAME)
|
||||
assert.equal(row.feesExempt, false)
|
||||
for (const key of ['chargePerPeriod', 'funds', 'payIntervalSec', 'periodsRemaining']) {
|
||||
assert.equal(row[key], null, `${key} must be null, not 0`)
|
||||
}
|
||||
assert.equal(row.nextPayAt, null)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.ownerAcct, null)
|
||||
})
|
||||
|
||||
test('an unparseable fee date is dropped rather than stored as an Invalid Date', () => {
|
||||
const row = market.flattenFrame({
|
||||
...FRAME,
|
||||
fees: { ...V5_FEES, dismissalAt: 'next tuesday', nextPayAt: null },
|
||||
})
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.nextPayAt, null)
|
||||
assert.equal(row.funds, 2960, 'one bad field must not discard the rest of the block')
|
||||
})
|
||||
|
||||
test('a malformed fees value is treated as absent, not as a crash', () => {
|
||||
for (const fees of ['', 0, 'nope', []]) {
|
||||
const row = market.flattenFrame({ ...FRAME, fees })
|
||||
assert.equal(row.feesExempt, false)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps
|
||||
assert.equal(typeof out[0].startedAt, 'number')
|
||||
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
|
||||
})
|
||||
|
||||
// ── Protocol 5: the decay schedule ─────────────────────────────────────────
|
||||
|
||||
test('upsertHouse flattens the nested schedule into its four columns', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'IDOC',
|
||||
schedule: {
|
||||
dynamicDecay: true,
|
||||
nextStage: '2026-09-02T04:00:00.000Z',
|
||||
decayPeriodSec: 432000,
|
||||
estimatedCollapse: '2026-09-02T04:00:00.000Z',
|
||||
},
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.dynamic_decay, 1)
|
||||
assert.equal(fields.decay_period_sec, 432000)
|
||||
assert.ok(fields.next_stage instanceof Date)
|
||||
assert.equal(fields.estimated_collapse.toISOString(), '2026-09-02T04:00:00.000Z')
|
||||
})
|
||||
|
||||
// The whole point of the field: under dynamic decay ServUO draws each stage's
|
||||
// duration at random on entry, so the shard omits estimatedCollapse everywhere but
|
||||
// IDOC. A stored null has to mean "not knowable", which it cannot if a partial
|
||||
// schedule silently keeps the previous value.
|
||||
test('a schedule without a collapse time stores null, it does not keep the old one', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'Greatly',
|
||||
schedule: { dynamicDecay: true, nextStage: '2026-09-01T00:00:00.000Z', decayPeriodSec: 432000 },
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.estimated_collapse, null)
|
||||
assert.ok('estimated_collapse' in fields, 'must be WRITTEN as null, not omitted')
|
||||
})
|
||||
|
||||
// A pre-v5 overlay sends no schedule at all, and a shard can be rolled back to one.
|
||||
// Every column is still written, so a dismissal date nobody is maintaining cannot
|
||||
// be left standing.
|
||||
test('a frame with no schedule nulls all four columns rather than omitting them', async () => {
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'Fairly' })
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
for (const col of ['next_stage', 'estimated_collapse', 'decay_period_sec', 'dynamic_decay']) {
|
||||
assert.ok(col in fields, `${col} must be written`)
|
||||
assert.equal(fields[col], null)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unparseable schedule date is dropped, not stored as an Invalid Date', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'IDOC',
|
||||
schedule: { nextStage: 'soon-ish', estimatedCollapse: '' },
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.next_stage, null)
|
||||
assert.equal(fields.estimated_collapse, null)
|
||||
})
|
||||
|
||||
// house.update writes owner_name from its own sweep. If house.decay coalesced a
|
||||
// missing ownerName to null, every decay transition on a pre-v5 shard would erase
|
||||
// a name the registry had already resolved.
|
||||
test('house.decay never erases an owner_name it was not given', async () => {
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerAcct: 'cadmus' })
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.ok(!('owner_name' in fields), 'owner_name must not be written when absent')
|
||||
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerName: 'Cadmus' })
|
||||
const [, withName] = calls.upsertHouse[1]
|
||||
assert.equal(withName.owner_name, 'Cadmus')
|
||||
})
|
||||
|
||||
@@ -476,3 +476,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
|
||||
visibility.forgetUser(6)
|
||||
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
|
||||
})
|
||||
|
||||
// ── Protocol 5 ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two new nested field groups and one new kind. All three exist as visibility
|
||||
// questions before they exist as features, which is the order this framework's
|
||||
// rule 2 is designed to force: a v5 field that nobody classified would either
|
||||
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
|
||||
// noticed). These tests pin the three answers that were actually chosen.
|
||||
|
||||
test('a vendor fee block is admin-only, and it is the whole block', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
|
||||
const frame = {
|
||||
serial: '0x40001234',
|
||||
shopName: "Darrow's Bargains",
|
||||
ownerName: 'Darrow',
|
||||
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
|
||||
fees: {
|
||||
exempt: false,
|
||||
chargePerPeriod: 148,
|
||||
funds: 2960,
|
||||
periodsRemaining: 20,
|
||||
dismissalAt: '2026-09-20T00:00:00.0000000Z',
|
||||
},
|
||||
}
|
||||
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const out = visibility.projectFeature('market', frame, level, config)
|
||||
assert.equal('fees' in out, false, `fees reached ${level}`)
|
||||
// The rest of the shop is untouched — this is a field rule, not a feature one.
|
||||
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
|
||||
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
|
||||
}
|
||||
|
||||
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
|
||||
assert.equal(asAdmin.fees.funds, 2960)
|
||||
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
|
||||
})
|
||||
|
||||
// The nesting is the point, not a style choice: projectValue matches literal JSON
|
||||
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
|
||||
// and a v6 field would default to visible. One nested key cannot drift.
|
||||
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
|
||||
const out = visibility.projectFeature('market', frame, 'staff', config)
|
||||
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
|
||||
})
|
||||
|
||||
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
|
||||
// page's entire content, and a house at IDOC is already announced in game.
|
||||
test('the decay schedule is anonymous by default but remains configurable', async () => {
|
||||
const frame = {
|
||||
serial: '0x1',
|
||||
to: 'IDOC',
|
||||
name: 'Marble Tower',
|
||||
schedule: {
|
||||
dynamicDecay: true,
|
||||
nextStage: '2026-09-02T04:00:00.0000000Z',
|
||||
decayPeriodSec: 432000,
|
||||
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
|
||||
},
|
||||
}
|
||||
|
||||
const config = await visibility.getConfig()
|
||||
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
|
||||
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
|
||||
|
||||
// A shard that considers a precise collapse time an unfair advantage can raise it,
|
||||
// and raising the one nested rule takes the whole schedule with it.
|
||||
withRows([
|
||||
{
|
||||
feature: 'houses',
|
||||
enabled: true,
|
||||
audience: 'anonymous',
|
||||
stream: true,
|
||||
fieldRules: { schedule: 'staff' },
|
||||
},
|
||||
])
|
||||
const tightened = await visibility.getConfig()
|
||||
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
|
||||
assert.equal(
|
||||
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
|
||||
432000,
|
||||
)
|
||||
// Tightening the schedule must not have disturbed the owner rules beside it.
|
||||
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
|
||||
})
|
||||
|
||||
// Rule 2, exercised on the kind it was added for. account.login.result says whether
|
||||
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
|
||||
// omission is the decision. If someone maps it to a feature to "make it visible",
|
||||
// this fails and says why.
|
||||
test('account.login.result is admin-only, like the attempt it completes', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
assert.equal(
|
||||
visibility.KIND_FEATURE.has('account.login.result'),
|
||||
false,
|
||||
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
|
||||
)
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
|
||||
}
|
||||
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
|
||||
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
|
||||
})
|
||||
|
||||
987
server/utils/shardEngagement.js
Normal file
987
server/utils/shardEngagement.js
Normal file
@@ -0,0 +1,987 @@
|
||||
// ── Shard event → engagement trigger ───────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 11. The third fan-out off `shardIngest.ingest`, beside the
|
||||
// SSE broadcast and the push tickle, and the one that produces a PER-PERSON
|
||||
// notification subject to a rule, a preference and a suppression. It is the twin
|
||||
// of `shardPush.js` and reads deliberately like it:
|
||||
//
|
||||
// • `shardStreams.mapShardEvent` turns a frame into push targets;
|
||||
// `mapShardEvent` here turns a frame into engagement events.
|
||||
// • Owner resolution is why neither can be a pure mapper: an owner-keyed target
|
||||
// names a GAME ACCOUNT, and turning that into a website user needs
|
||||
// `shardLinks`. An unlinked account is simply nobody to notify.
|
||||
//
|
||||
// **Nothing here decides who is told.** It says what happened and (for an
|
||||
// owner- or members-shaped event) who it is ABOUT; the engine applies the rules,
|
||||
// the ceiling, the preferences and the suppression list. That split is the
|
||||
// module boundary: a module cannot send mail (§1.2) and this is not the back door.
|
||||
//
|
||||
// **Never throws, never blocks ingest.** `ingest()` calls this fire-and-forget
|
||||
// exactly as it calls the broadcast and the push dispatch, and every mapper below
|
||||
// is wrapped so one bad frame cannot stop the feed. This is the same reason the
|
||||
// C# side's `Emit()` enqueues and returns rather than touching the socket from the
|
||||
// Core thread.
|
||||
//
|
||||
// ── Three things that are NOT a plain field mapping ────────────────────────
|
||||
//
|
||||
// Most of §8.6's rows are "read four fields off the frame and emit". Three are
|
||||
// not, and each is here rather than in a rule because a rule cannot express it:
|
||||
//
|
||||
// 1. **Transitions.** `champ.update` and `city.update` are full-state UPSERTS
|
||||
// re-emitted on any change, not discrete "started"/"elected" events. Without
|
||||
// a per-process transition tracker, a reconnect snapshot is read as twenty
|
||||
// champion spawns starting at once. `shardStreams.js` already solved this for
|
||||
// push and this file uses the same shape — and the same rule that a FIRST
|
||||
// sighting is never a transition.
|
||||
// 2. **Thresholds.** `uo.vendor.expiring` and `uo.economy.milestone` fire when a
|
||||
// value CROSSES a line. `conditions.js` compares a declared variable against
|
||||
// a literal and has no relative-time or previous-value operator, so
|
||||
// "within 24 hours of dismissal" and "gold passed a billion" are not
|
||||
// expressible as conditions — and `vendor.listing` is a sweep frame
|
||||
// re-emitted on every price change, so emitting per frame would flood. The
|
||||
// crossing is tracked here; the operator still narrows with
|
||||
// `hoursRemaining is at most N`.
|
||||
// 3. **Audience resolution for `members`.** A guild event is about the members
|
||||
// of THAT guild, which is a different answer for every firing and therefore
|
||||
// cannot be a saved segment (whose params are constants). The access-checked
|
||||
// set travels on the envelope as `recipientUserIds` — Phase 6's decision 2,
|
||||
// and the mechanism the Team fan-out was built on.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../model/shardState/shardState.model')
|
||||
const { TRIGGER_IDS } = require('../config/shardTriggers')
|
||||
const { PATHS, guildPath } = require('../config/clientPaths')
|
||||
const core = require('../core')
|
||||
|
||||
const log = core.logger('shard-engagement')
|
||||
|
||||
// ── Thresholds ─────────────────────────────────────────────────────────────
|
||||
|
||||
// When a vendor becomes "expiring". Hours rather than pay periods, because a pay
|
||||
// period is a real day under the new vendor system and a UO day (~2 real hours)
|
||||
// under the old one — the exact factor-of-twelve trap `docs/link/v5.md` records,
|
||||
// and the reason the wire carries `dismissalAt` as an instant.
|
||||
//
|
||||
// 48 hours is one full real day of warning even on a shard whose owner logs in
|
||||
// daily, and it is the OUTER edge: the mapper fires once on the way in, and the
|
||||
// operator narrows further with `hoursRemaining is at most 24` if they want less.
|
||||
const VENDOR_WARN_HOURS = 48
|
||||
|
||||
// Gold-supply reporting lines, ascending. Crossing one in either direction is one
|
||||
// `uo.economy.milestone`. They are the module's rather than the operator's for
|
||||
// now: an admin-configurable ladder is a settings surface, and this phase's job
|
||||
// is the trigger. An operator who wants a different line writes a rule condition
|
||||
// on `value`.
|
||||
const GOLD_THRESHOLDS = [
|
||||
100_000_000, 250_000_000, 500_000_000, 1_000_000_000,
|
||||
2_500_000_000, 5_000_000_000, 10_000_000_000,
|
||||
]
|
||||
|
||||
// The same, for account count.
|
||||
const ACCOUNT_THRESHOLDS = [100, 250, 500, 1000, 2500, 5000, 10_000]
|
||||
|
||||
// Which line a value sits above, as an index. -1 means "below the first".
|
||||
const bandOf = (value, thresholds) => {
|
||||
let band = -1
|
||||
for (let i = 0; i < thresholds.length; i += 1) if (value >= thresholds[i]) band = i
|
||||
return band
|
||||
}
|
||||
|
||||
// ── The transition tracker ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-process state for the upsert kinds and the threshold kinds.
|
||||
*
|
||||
* Injectable so a test gets a fresh one; a module-level default backs the live
|
||||
* dispatcher. It is deliberately NOT persisted: its whole job is to say "has
|
||||
* this process seen a previous value", and a value restored from a database
|
||||
* would make the first frame after a restart a transition against state the
|
||||
* shard may have left behind hours ago.
|
||||
*/
|
||||
function createTracker() {
|
||||
return {
|
||||
champActive: new Map(), // spawn serial → boolean
|
||||
champBossUp: new Map(), // spawn serial → boolean
|
||||
cityGovernor: new Map(), // city → governor serial or null
|
||||
cityPhase: new Map(), // city → electionPhase
|
||||
vendorWarned: new Map(), // vendor serial → boolean (already inside the window)
|
||||
pointsLeader: new Map(), // points system → leader serial
|
||||
economyBand: new Map(), // metric → band index
|
||||
serverUp: null, // boolean or null (never seen)
|
||||
}
|
||||
}
|
||||
const defaultTracker = createTracker()
|
||||
|
||||
/** Reset the module-level tracker. For tests and for `shardIngest.reset()`. */
|
||||
function reset() {
|
||||
const fresh = createTracker()
|
||||
for (const key of Object.keys(fresh)) defaultTracker[key] = fresh[key]
|
||||
}
|
||||
|
||||
// ── Small shared shapes ────────────────────────────────────────────────────
|
||||
|
||||
// "Felucca 1480, 1600" — one string rather than four variables, because a
|
||||
// template that has to assemble coordinates is a template every author gets
|
||||
// slightly differently. Returns undefined when there is nothing to format, so it
|
||||
// drops out of an optional variable rather than rendering "undefined , ".
|
||||
function place(ev) {
|
||||
const map = ev.map || (ev.location && ev.location.map)
|
||||
const x = ev.x ?? (ev.location && ev.location.x)
|
||||
const y = ev.y ?? (ev.location && ev.location.y)
|
||||
if (!map && x == null) return undefined
|
||||
const coords = x == null || y == null ? '' : ` ${x}, ${y}`
|
||||
const region = ev.region || (ev.location && ev.location.region)
|
||||
const suffix = region ? ` (${region})` : ''
|
||||
return `${map || ''}${coords}${suffix}`.trim() || undefined
|
||||
}
|
||||
|
||||
// An actor object's display name, whichever of the shard's shapes it arrives in.
|
||||
const actorName = (actor) => (actor && typeof actor === 'object' ? actor.name : undefined) || undefined
|
||||
const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct : undefined) || undefined
|
||||
|
||||
// Drop the undefined values before they reach `emit`. A declared OPTIONAL
|
||||
// variable that arrives as `undefined` is dropped by `validatePayload` anyway,
|
||||
// but building the object without them keeps the emit log's `variables` list
|
||||
// honest about what the frame actually carried.
|
||||
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
|
||||
|
||||
|
||||
// ── Presentational fragments (ENGAGEMENT.md Phase 11b, decision 8) ─────────
|
||||
//
|
||||
// **A template has no conditionals, by design** (`interpolate.js`: no filters,
|
||||
// no loops, no ternaries), and an unset optional interpolates to the EMPTY
|
||||
// STRING. That is exactly right for `notify.event`, whose variables are
|
||||
// structural — but the in-universe bodies are sentences, and a sentence with a
|
||||
// hole in the middle of it reads as a bug: "The house , in , stands in peril."
|
||||
//
|
||||
// So the ternary stays at the call site and its RESULT arrives as a declared
|
||||
// optional variable, which is Phase 5a's `forWhom` precedent unchanged. Two
|
||||
// shapes, and the difference matters when you write one:
|
||||
//
|
||||
// • a LABEL always has a value, so it can carry a sentence's spine
|
||||
// (`houseLabel` is a name, or a seal number when there is no name);
|
||||
// • a TRAILING FRAGMENT may be empty and leads with its own space, so the
|
||||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||||
//
|
||||
// Every one of them is declared `required: false` on the trigger with an
|
||||
// `example` showing precisely what it produces, leading space included — which
|
||||
// is what the template editor previews and test-sends with.
|
||||
|
||||
/** A trailing fragment, or undefined when there is nothing to say. */
|
||||
const trailing = (value, build) => (value ? build(value) : undefined)
|
||||
|
||||
// "The Silver Anvil, in Britain" · "the house under seal 0x40001234". A house
|
||||
// often has no name and sometimes no region, and the warning has to name
|
||||
// SOMETHING the owner can act on — a seal number is worse prose and better than
|
||||
// a blank.
|
||||
const houseLabel = (name, region, serial) => {
|
||||
const named = name ? `“${name}”` : `the house under seal ${serial}`
|
||||
return region ? `${named}, in ${region}` : named
|
||||
}
|
||||
|
||||
// The decay stages as words rather than as the wire's enum. `Greatly` in the
|
||||
// middle of a sentence is the shard's vocabulary leaking into a letter.
|
||||
const STAGE_WORDS = {
|
||||
FAIRLY: 'fairly worn',
|
||||
GREATLY: 'greatly worn',
|
||||
IDOC: 'in imminent danger of collapse',
|
||||
}
|
||||
const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] || 'in decay'
|
||||
|
||||
// The election phases likewise: `nominate` and `vote` are wire values.
|
||||
// A whole DETAIL LINE, assembled from the parts that are actually present.
|
||||
//
|
||||
// The same argument `place()` above makes, one level up: a template that has to
|
||||
// assemble four optional numbers into a sentence is a template every author gets
|
||||
// slightly differently, and one whose optionals are absent renders
|
||||
// "On hand: gold. Charged each period: gold." — which is what the render sweep
|
||||
// found on a pre-v5 vendor frame. Passing the assembled line means the template
|
||||
// interpolates ONE variable and the empty case is empty rather than punctuated.
|
||||
// A wire instant as a person reads it: "2 September 2026, 04:06 UTC".
|
||||
//
|
||||
// Core deliberately has no interpolation filters (`interpolate.js` — no ternaries,
|
||||
// no formatters), so a `datetime` variable renders as whatever string the payload
|
||||
// holds — and the wire's is an ISO-8601 stamp with seven decimal places, which is
|
||||
// what a letter from the Merchants' Guild was signing off with. Same argument as
|
||||
// `place()` and `detailLine()` one line down: the presentation is assembled here,
|
||||
// at the call site, and arrives as its own value.
|
||||
//
|
||||
// **The machine value is never replaced.** `dismissalAt` and `autoPickAt` are
|
||||
// declared `datetime` and an operator can write `is at most` conditions against
|
||||
// them (`conditions.js`), so the readable form is an ADDITIONAL variable and the
|
||||
// ISO one stays exactly as it was.
|
||||
const readableTime = (iso) => {
|
||||
if (!iso) return undefined
|
||||
const at = new Date(iso)
|
||||
if (Number.isNaN(at.getTime())) return undefined
|
||||
const day = at.getUTCDate()
|
||||
const month = MONTHS[at.getUTCMonth()]
|
||||
const hh = String(at.getUTCHours()).padStart(2, '0')
|
||||
const mm = String(at.getUTCMinutes()).padStart(2, '0')
|
||||
return `${day} ${month} ${at.getUTCFullYear()}, ${hh}:${mm} UTC`
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const detailLine = (parts) => {
|
||||
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
|
||||
}
|
||||
|
||||
const PHASE_WORDS = { nominate: 'Nominations are open', vote: 'The ballot is open' }
|
||||
const phaseLabel = (phase) => PHASE_WORDS[String(phase || '')] || 'The election has moved'
|
||||
|
||||
// ── The mappers ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
|
||||
// dedupeKey? }`. Resolution — account → user id, guild → member ids — happens in
|
||||
// `dispatch` below, because it needs the database and these must not.
|
||||
//
|
||||
// `ownerAccount` is the same field name `shardStreams.js` uses for the same idea,
|
||||
// so the two mappers can be read side by side.
|
||||
|
||||
const decayName = (ev) => ev.name || undefined
|
||||
|
||||
const MAPPERS = {
|
||||
// ── Owned asset at risk ────────────────────────────────────────────────
|
||||
'house.decay': (ev, tracker, out) => {
|
||||
const to = String(ev.to || '').toUpperCase()
|
||||
const serial = ev.serial == null ? null : String(ev.serial)
|
||||
if (!serial) return
|
||||
// COLLAPSED is its own trigger; the late stages are the warning. `LikeNew`
|
||||
// and the early stages are not news — a house being refreshed is the normal
|
||||
// case and mailing it would make the warning worthless.
|
||||
if (to === 'COLLAPSED') {
|
||||
out.push({
|
||||
triggerId: 'uo.house.collapsed',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
houseSerial: serial,
|
||||
houseName: decayName(ev),
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
whereLine: detailLine([['Last recorded at', place(ev)]]),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
// **The good outcome** (Phase 11b decision 11). A house refreshed back to
|
||||
// LikeNew is what `uo.house.idoc_warning`'s 900-second delay exists to give
|
||||
// the owner time to do, and until this branch the refresh reached the engine
|
||||
// as silence — so the delay was a late mail rather than a cancellable one.
|
||||
// Nothing on the wire changed: the decay sweep has always emitted this
|
||||
// transition, and the early return below was swallowing it.
|
||||
// **`AGELESS` as well as `LIKENEW`, and the first is the commoner case.**
|
||||
// ServUO reports `LikeNew` for a house that is still on a decay clock and
|
||||
// has just been refreshed (`DecayType.ManualRefresh`), and `Ageless` for one
|
||||
// that is no longer on a clock at all — which is what the owner's newest
|
||||
// house becomes the moment they log back in, because `DecayType` flips to
|
||||
// `AutoRefresh` and the getter stops advancing the stage. A returning player
|
||||
// is the ordinary way a decaying house is rescued, so reading only `LikeNew`
|
||||
// would miss most rescues. Both mean "out of danger", which is what this
|
||||
// trigger says.
|
||||
if (to === 'LIKENEW' || to === 'AGELESS') {
|
||||
out.push({
|
||||
triggerId: 'uo.house.refreshed',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
houseSerial: serial,
|
||||
houseUrl: PATHS.houses,
|
||||
houseName: decayName(ev),
|
||||
previousStage: ev.from || undefined,
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
// A TRAILING FRAGMENT, so it leads with its own space and the sentence
|
||||
// closes cleanly without it. Built only for a stage that has a word —
|
||||
// "It stood in decay." is the fallback label leaking into prose, and an
|
||||
// empty fragment reads better than that.
|
||||
fromLine: trailing(
|
||||
STAGE_WORDS[String(ev.from || '').toUpperCase()] ? ev.from : null,
|
||||
(stage) => ` It stood ${stageLabel(stage)}.`,
|
||||
),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
|
||||
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
|
||||
out.push({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
houseSerial: serial,
|
||||
houseUrl: PATHS.houses,
|
||||
houseName: decayName(ev),
|
||||
stage: ev.to,
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
stageLabel: stageLabel(ev.to),
|
||||
whereLine: detailLine([['Recorded at', place(ev)], ['Stage entered', ev.to]]),
|
||||
previousStage: ev.from || undefined,
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
// **Both optional, and both genuinely absent much of the time.** A v4
|
||||
// overlay sends no `schedule` at all; a dynamic-decay shard omits
|
||||
// `estimatedCollapse` at every stage before IDOC because ServUO draws
|
||||
// each stage's duration at random when the stage is entered. Passing
|
||||
// `undefined` through is the honest thing — `docs/link/v5.md` is explicit
|
||||
// that absence means "not knowable", never "not yet read", and computing
|
||||
// a fallback here would republish exactly the guess the shard refused to.
|
||||
nextStage: schedule.nextStage || undefined,
|
||||
estimatedCollapse: schedule.estimatedCollapse || undefined,
|
||||
lastRefreshed: ev.lastRefreshed || undefined,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// `house.remove` carries ONLY a serial — the house is gone, so the frame has
|
||||
// nothing else to say. The owner comes from this module's own registry mirror,
|
||||
// which is a database read and therefore happens in `dispatch`.
|
||||
'house.remove': (ev, tracker, out) => {
|
||||
if (ev.serial == null) return
|
||||
out.push({
|
||||
triggerId: 'uo.house.collapsed',
|
||||
houseSerial: String(ev.serial),
|
||||
data: { houseSerial: String(ev.serial) },
|
||||
})
|
||||
},
|
||||
|
||||
'vendor.listing': (ev, tracker, out) => {
|
||||
const serial = ev.serial == null ? null : String(ev.serial)
|
||||
if (!serial || !ev.ownerAcct) return
|
||||
const fees = ev.fees && typeof ev.fees === 'object' ? ev.fees : null
|
||||
// A pre-v5 overlay sends no `fees`; a commission vendor sends `{exempt:true}`
|
||||
// and is NEVER dismissed for them. Both mean "nothing to warn about", and
|
||||
// conflating exempt with a distant date is how a vendor that cannot expire
|
||||
// ends up in an expiry warning (`docs/link/v5.md`).
|
||||
if (!fees || fees.exempt === true || !fees.dismissalAt) {
|
||||
tracker.vendorWarned.delete(serial)
|
||||
return
|
||||
}
|
||||
const at = new Date(fees.dismissalAt)
|
||||
if (Number.isNaN(at.getTime())) return
|
||||
const hours = Math.floor((at.getTime() - Date.now()) / 3_600_000)
|
||||
const inWindow = hours <= VENDOR_WARN_HOURS
|
||||
const wasWarned = tracker.vendorWarned.get(serial) === true
|
||||
tracker.vendorWarned.set(serial, inWindow)
|
||||
// **Only the CROSSING.** The sweep re-emits a shop on any price change, so
|
||||
// without this a vendor inside the window mails its owner every time somebody
|
||||
// reprices a longsword. Leaving the window (a deposit) clears the flag above,
|
||||
// so the next approach warns again — which is the behaviour an owner wants.
|
||||
if (!inWindow || wasWarned) return
|
||||
out.push({
|
||||
triggerId: 'uo.vendor.expiring',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
vendorSerial: serial,
|
||||
marketUrl: PATHS.market,
|
||||
shopName: ev.shopName || undefined,
|
||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||
dismissalAt: fees.dismissalAt,
|
||||
// Never negative: a vendor already past its dismissal tick is being
|
||||
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
|
||||
hoursRemaining: Math.max(0, hours),
|
||||
periodsRemaining: Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined,
|
||||
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
|
||||
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
|
||||
location: place(ev),
|
||||
ledgerLine: detailLine([
|
||||
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
|
||||
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
|
||||
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
|
||||
['Dismissal', readableTime(fees.dismissalAt)],
|
||||
['Standing at', place(ev)],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'vendor.listing.remove': (ev, tracker) => {
|
||||
if (ev.serial != null) tracker.vendorWarned.delete(String(ev.serial))
|
||||
},
|
||||
|
||||
// ── Passive income ─────────────────────────────────────────────────────
|
||||
'vendor.sale': (ev, tracker, out) => {
|
||||
if (!ev.ownerAcct) return
|
||||
out.push({
|
||||
triggerId: 'uo.vendor.sale',
|
||||
ownerAccount: ev.ownerAcct,
|
||||
data: defined({
|
||||
vendorSerial: String(ev.vendorSerial ?? ''),
|
||||
itemName: ev.itemType || 'an item',
|
||||
// "3 × Iron Ingot" or just "Iron Ingot" — `amount` is optional and a
|
||||
// sentence reading "sold Iron Ingot" is the hole this closes.
|
||||
itemLine: Number.isFinite(ev.amount) && ev.amount > 1
|
||||
? `${ev.amount} × ${ev.itemType || 'an item'}`
|
||||
: (ev.itemType || 'an item'),
|
||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
|
||||
price: Number.isFinite(ev.price) ? ev.price : 0,
|
||||
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
|
||||
ledgerLine: detailLine([
|
||||
['Commission withheld', Number.isFinite(ev.commission) ? `${ev.commission} gold` : undefined],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// ── Personal security ──────────────────────────────────────────────────
|
||||
//
|
||||
// **`account.login.result` and NOT `account.login.attempt`.** The attempt fires
|
||||
// from `EventSink.AccountLogin`, which runs before the auth decision — the
|
||||
// emitter's own comment says so — and `AccountLoginEventArgs` constructs with
|
||||
// `Accepted = true`, so a rule on it would have mailed a security alert every
|
||||
// time the player logged in successfully. That inversion is why protocol 5 adds
|
||||
// this kind and why the trigger is named `login_failed` rather than `attempt`.
|
||||
'account.login.result': (ev, tracker, out) => {
|
||||
if (!ev.acct) return
|
||||
if (ev.accepted !== false) return
|
||||
out.push({
|
||||
triggerId: 'uo.account.login_failed',
|
||||
ownerAccount: ev.acct,
|
||||
data: defined({
|
||||
account: String(ev.acct),
|
||||
reason: ev.reason || undefined,
|
||||
ip: ev.ip || undefined,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// **Resolved BEFORE ingest drops the link mirror**, which is the whole reason
|
||||
// this file is called from `ingest()` ahead of the state write rather than
|
||||
// after it. `applyStateChange` removes the `shard_account_links` row for this
|
||||
// account, so an owner lookup that ran afterwards would find nobody and the one
|
||||
// person who needs to know their account was unlinked would never be told.
|
||||
'account.unlinked': (ev, tracker, out) => {
|
||||
if (!ev.account) return
|
||||
out.push({
|
||||
triggerId: 'uo.account.unlinked',
|
||||
ownerAccount: ev.account,
|
||||
data: defined({
|
||||
account: String(ev.account),
|
||||
characterName: ev.char || undefined,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// ── Personal milestone ─────────────────────────────────────────────────
|
||||
'skill.gain': (ev, tracker, out) => {
|
||||
// The cap, and only the cap. `skill.gain` fires on every tenth of a point;
|
||||
// `base >= cap` is the milestone and everything else is noise.
|
||||
if (!Number.isFinite(ev.base) || !Number.isFinite(ev.cap) || ev.base < ev.cap) return
|
||||
const acct = actorAcct(ev.who)
|
||||
if (!acct) return
|
||||
out.push({
|
||||
triggerId: 'uo.skill.capped',
|
||||
ownerAccount: acct,
|
||||
data: defined({
|
||||
characterName: actorName(ev.who) || 'your character',
|
||||
skill: String(ev.skill || 'a skill'),
|
||||
cap: ev.cap,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'quest.complete': (ev, tracker, out) => {
|
||||
const acct = actorAcct(ev.who)
|
||||
if (!acct) return
|
||||
out.push({
|
||||
triggerId: 'uo.quest.complete',
|
||||
ownerAccount: acct,
|
||||
data: defined({
|
||||
characterName: actorName(ev.who) || 'your character',
|
||||
quest: String(ev.quest || 'a quest'),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'player.death': (ev, tracker, out) => {
|
||||
const acct = actorAcct(ev.who)
|
||||
if (!acct) return
|
||||
out.push({
|
||||
triggerId: 'uo.character.death',
|
||||
ownerAccount: acct,
|
||||
data: defined({
|
||||
characterName: actorName(ev.who) || 'your character',
|
||||
killerName: actorName(ev.killer),
|
||||
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'player.murdered': (ev, tracker, out) => {
|
||||
const acct = actorAcct(ev.victim)
|
||||
if (!acct) return
|
||||
out.push({
|
||||
triggerId: 'uo.character.murdered',
|
||||
ownerAccount: acct,
|
||||
data: defined({
|
||||
characterName: actorName(ev.victim) || 'your character',
|
||||
murdererName: actorName(ev.murderer),
|
||||
slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// ── Social / civic ─────────────────────────────────────────────────────
|
||||
//
|
||||
// `uo.guild.joined` is NOT here: core's `team.member.joined` already fires for
|
||||
// it on every roster reconcile, because a UO guild is a Team and this module is
|
||||
// the Team provider. See ENGAGEMENT.md §8.6 for the carve-out.
|
||||
'guild.leave': (ev, tracker, out) => {
|
||||
if (ev.id == null) return
|
||||
out.push({
|
||||
triggerId: 'uo.guild.left',
|
||||
guildId: ev.id,
|
||||
// `who` is a bare SERIAL string here, not an actor object — the mobile has
|
||||
// already left, so there is nothing for the shard to attribute. The name is
|
||||
// looked up from the roster mirror in `dispatch`.
|
||||
memberSerial: ev.who == null ? null : String(ev.who),
|
||||
data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
|
||||
})
|
||||
},
|
||||
|
||||
'guild.remove': (ev, tracker, out) => {
|
||||
if (ev.id == null) return
|
||||
out.push({
|
||||
triggerId: 'uo.guild.disbanded',
|
||||
guildId: ev.id,
|
||||
// The frame carries ONLY the id, so the name comes from the board mirror in
|
||||
// `dispatch` — and it has to be read there before `applyStateChange` drops
|
||||
// the row, the same ordering `account.unlinked` depends on.
|
||||
data: {},
|
||||
})
|
||||
},
|
||||
|
||||
'city.update': (ev, tracker, out) => {
|
||||
const { city } = ev
|
||||
if (!city) return
|
||||
|
||||
// A new governor. Never on FIRST sight (`prev === undefined`), so a reconnect
|
||||
// snapshot is not read as eight simultaneous elections.
|
||||
const gov = ev.governor && ev.governor.serial != null ? String(ev.governor.serial) : null
|
||||
const prevGov = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
if (prevGov !== undefined && gov && gov !== prevGov) {
|
||||
const governorName = actorName(ev.governor) || 'a new governor'
|
||||
// **The frame carries no previous holder by name.** The tracker holds the
|
||||
// outgoing governor's SERIAL and nothing maps a serial to a name here, so
|
||||
// the succession fragment is empty today and the declaration is optional.
|
||||
// It is declared rather than omitted so the body does not have to be
|
||||
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
||||
const civic = defined({
|
||||
city: String(city),
|
||||
governorsUrl: PATHS.governors,
|
||||
governorName,
|
||||
previousGovernorName: undefined,
|
||||
inSuccessionTo: undefined,
|
||||
})
|
||||
out.push({ triggerId: 'uo.governor.elected', data: civic })
|
||||
|
||||
// **And the letter to the person who won** (Phase 11b, decision 10). Same
|
||||
// frame, same transition, same never-on-first-sight guard — a different
|
||||
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
|
||||
// every actor object it emits, so this needs no protocol change; an
|
||||
// unlinked governor is simply nobody to write to, which `resolveTarget`
|
||||
// already treats as an ordinary outcome rather than an error.
|
||||
const acct = actorAcct(ev.governor)
|
||||
if (acct) {
|
||||
out.push({
|
||||
triggerId: 'uo.governor.appointed',
|
||||
ownerAccount: acct,
|
||||
data: { ...civic },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
|
||||
// change that arrives without one is not emitted at all rather than emitted
|
||||
// as a deadline-less call to action — which is what a "vote now" mail with
|
||||
// nothing to act by would be.
|
||||
const phase = ev.electionPhase || 'none'
|
||||
const prevPhase = tracker.cityPhase.get(city)
|
||||
tracker.cityPhase.set(city, phase)
|
||||
if (
|
||||
prevPhase !== undefined
|
||||
&& phase !== prevPhase
|
||||
&& (phase === 'nominate' || phase === 'vote')
|
||||
&& ev.autoPickAt
|
||||
) {
|
||||
out.push({
|
||||
triggerId: 'uo.election.opened',
|
||||
data: defined({
|
||||
city: String(city),
|
||||
governorsUrl: PATHS.governors,
|
||||
phase,
|
||||
phaseLabel: phaseLabel(phase),
|
||||
autoPickAt: ev.autoPickAt,
|
||||
autoPickWhen: readableTime(ev.autoPickAt),
|
||||
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
||||
candidateNote: trailing(
|
||||
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
|
||||
(n) => (n === 1 ? ' One candidate stands.' : ` ${n} candidates stand.`),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ── Come online now ────────────────────────────────────────────────────
|
||||
'champ.update': (ev, tracker, out) => {
|
||||
const serial = ev.serial == null ? null : String(ev.serial)
|
||||
if (!serial) return
|
||||
const isActive = ev.active === true
|
||||
const wasActive = tracker.champActive.get(serial)
|
||||
tracker.champActive.set(serial, isActive)
|
||||
|
||||
const base = defined({
|
||||
spawnSerial: serial,
|
||||
champsUrl: PATHS.champs,
|
||||
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||
category: ev.category || undefined,
|
||||
location: place(ev),
|
||||
atPlace: trailing(place(ev), (p) => ` at ${p}`),
|
||||
})
|
||||
|
||||
if (wasActive !== undefined && isActive && wasActive !== true) {
|
||||
out.push({ triggerId: 'uo.champ.started', data: base })
|
||||
}
|
||||
|
||||
const bossUp = ev.bossUp === true
|
||||
const wasBossUp = tracker.champBossUp.get(serial)
|
||||
tracker.champBossUp.set(serial, bossUp)
|
||||
if (wasBossUp !== undefined && bossUp && wasBossUp !== true) {
|
||||
out.push({
|
||||
triggerId: 'uo.champ.boss_up',
|
||||
data: defined({ ...base, bossName: ev.boss || undefined }),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
'champ.remove': (ev, tracker) => {
|
||||
if (ev.serial == null) return
|
||||
tracker.champActive.delete(String(ev.serial))
|
||||
tracker.champBossUp.delete(String(ev.serial))
|
||||
},
|
||||
|
||||
// **`server.hello` fires on every sidecar reconnect, not only on a shard
|
||||
// restart** — which is exactly the flapping this trigger must not amplify. The
|
||||
// tracker's `serverUp` is the guard: a hello while we already believe the shard
|
||||
// is up is a reconnect and emits nothing. The seeded rule's hard cooldown is the
|
||||
// second line of defence, for a shard genuinely bouncing.
|
||||
'server.hello': (ev, tracker, out) => {
|
||||
const wasUp = tracker.serverUp
|
||||
tracker.serverUp = true
|
||||
if (wasUp === true) return
|
||||
out.push({
|
||||
triggerId: 'uo.server.up',
|
||||
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
|
||||
})
|
||||
},
|
||||
|
||||
'server.shutdown': (ev, tracker, out) => {
|
||||
if (tracker.serverUp === false) return
|
||||
tracker.serverUp = false
|
||||
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
|
||||
},
|
||||
|
||||
'server.crashed': (ev, tracker, out) => {
|
||||
if (tracker.serverUp === false) return
|
||||
tracker.serverUp = false
|
||||
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
|
||||
},
|
||||
|
||||
// ── Leaderboard ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `subscribers` only. `top[]` names a mobile SERIAL and `shard_account_links`
|
||||
// is keyed by game ACCOUNT, so the "you were pushed out" half of §8.6's row is
|
||||
// carved out rather than resolved for whoever happens to be online.
|
||||
'points.board': (ev, tracker, out) => {
|
||||
const system = ev.system
|
||||
const top = Array.isArray(ev.top) ? ev.top : []
|
||||
if (!system || !top.length) return
|
||||
const leader = top.find((e) => e && e.rank === 1) || top[0]
|
||||
if (!leader || leader.serial == null) return
|
||||
const serial = String(leader.serial)
|
||||
const prev = tracker.pointsLeader.get(system)
|
||||
tracker.pointsLeader.set(system, serial)
|
||||
if (prev === undefined || prev === serial) return
|
||||
out.push({
|
||||
triggerId: 'uo.points.rank_changed',
|
||||
data: defined({
|
||||
system: String(system),
|
||||
systemName: ev.nameString || undefined,
|
||||
leaderName: leader.name || 'a new leader',
|
||||
// The board frame carries no previous holder — the tracker holds only a
|
||||
// SERIAL, and a serial is not a name — so this is the one family whose
|
||||
// trailing fragment is always empty today. Declared anyway, because the
|
||||
// alternative is a body that has to be rewritten when the frame gains it.
|
||||
boardLabel: ev.nameString || String(system),
|
||||
points: Number.isFinite(leader.points) ? leader.points : undefined,
|
||||
standingLine: Number.isFinite(leader.points)
|
||||
? `${leader.name || 'a new leader'} now stands first upon it, with ${leader.points} to their name.`
|
||||
: `${leader.name || 'a new leader'} now stands first upon it.`,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// ── Staff-facing ───────────────────────────────────────────────────────
|
||||
'page.new': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.page.new',
|
||||
data: defined({
|
||||
pagesUrl: PATHS.ops,
|
||||
pageType: String(ev.type || 'Other'),
|
||||
senderName: actorName(ev.sender),
|
||||
message: ev.message || undefined,
|
||||
location: place(ev),
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'cheat.fastwalk': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.cheat.detected',
|
||||
data: defined({
|
||||
characterName: actorName(ev.who) || 'an unnamed character',
|
||||
account: actorAcct(ev.who),
|
||||
ip: ev.ip || undefined,
|
||||
detector: 'fastwalk',
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
// ── Operator-facing ────────────────────────────────────────────────────
|
||||
'audit.set': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.audit.staff_action',
|
||||
data: defined({
|
||||
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
|
||||
action: 'set',
|
||||
detail: ev.prop ? `${ev.prop}: ${ev.old ?? '?'} → ${ev.new ?? '?'}` : undefined,
|
||||
target: ev.target || undefined,
|
||||
origin: 'in-game',
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'audit.command': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.audit.staff_action',
|
||||
data: defined({
|
||||
staffName: actorName(ev.staff) || (typeof ev.staff === 'string' ? ev.staff : undefined),
|
||||
action: 'command',
|
||||
detail: ev.command ? `${ev.command} ${ev.args || ''}`.trim() : undefined,
|
||||
origin: 'in-game',
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'admin.audit': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.audit.staff_action',
|
||||
data: defined({
|
||||
staffName: typeof ev.actor === 'string' ? ev.actor : actorName(ev.actor),
|
||||
action: String(ev.action || 'action'),
|
||||
detail: ev.reason || undefined,
|
||||
target: ev.target || undefined,
|
||||
origin: ev.origin || undefined,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
'economy.supply': (ev, tracker, out) => {
|
||||
for (const [metric, value, thresholds] of [
|
||||
['gold', ev.gold, GOLD_THRESHOLDS],
|
||||
['accounts', ev.accounts, ACCOUNT_THRESHOLDS],
|
||||
]) {
|
||||
if (!Number.isFinite(value)) continue
|
||||
const band = bandOf(value, thresholds)
|
||||
const prev = tracker.economyBand.get(metric)
|
||||
tracker.economyBand.set(metric, band)
|
||||
// First sighting establishes the band and reports nothing. Otherwise a
|
||||
// sidecar reconnect on a mature shard announces "gold passed a billion"
|
||||
// about a line it crossed months ago.
|
||||
if (prev === undefined || prev === band) continue
|
||||
// The line that was crossed is the HIGHER of the two bands under a rise and
|
||||
// the one just left under a fall, so both directions name the line the
|
||||
// reader is thinking about.
|
||||
const crossed = band > prev ? thresholds[band] : thresholds[prev]
|
||||
out.push({
|
||||
triggerId: 'uo.economy.milestone',
|
||||
data: defined({
|
||||
economyUrl: PATHS.shard,
|
||||
metric,
|
||||
value: Math.round(value),
|
||||
threshold: crossed,
|
||||
direction: band > prev ? 'up' : 'down',
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
'world.save.after': (ev, tracker, out) => {
|
||||
out.push({
|
||||
triggerId: 'uo.world.saved',
|
||||
data: defined({
|
||||
items: Number.isFinite(ev.items) ? ev.items : undefined,
|
||||
mobiles: Number.isFinite(ev.mobiles) ? ev.mobiles : undefined,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one shard event to zero or more engagement events. Pure given `tracker`.
|
||||
*
|
||||
* Exported so the mapping can be tested without a database, exactly as
|
||||
* `shardStreams.mapShardEvent` is.
|
||||
*/
|
||||
function mapShardEvent(event, tracker = defaultTracker) {
|
||||
if (!event || typeof event.kind !== 'string') return []
|
||||
const mapper = MAPPERS[event.kind]
|
||||
if (!mapper) return []
|
||||
const out = []
|
||||
mapper(event, tracker, out)
|
||||
// **Defence in depth, and the exact counterpart of `shardStreams.js`'s
|
||||
// public-allowlist filter.** A target naming an id this module does not declare
|
||||
// cannot be delivered — `emit` would refuse it anyway, throwing in dev and
|
||||
// logging in prod — so catching it here turns a typo into one warning with the
|
||||
// id in it rather than an exception on the ingest path.
|
||||
return out.filter((t) => {
|
||||
if (TRIGGER_IDS.has(t.triggerId)) return true
|
||||
log.warn('mapper produced an undeclared trigger id', { kind: event.kind, triggerId: t.triggerId })
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// ── Resolution and dispatch ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Turn one mapped target into the envelope `ctx.events.emit` takes, or null when
|
||||
* there is nobody to tell.
|
||||
*
|
||||
* This is the half that reaches the database, and it is why the mapping above is
|
||||
* separate: an owner-keyed target names a GAME ACCOUNT and a members-keyed one
|
||||
* names a GUILD, and neither is a website user until something asks.
|
||||
*/
|
||||
async function resolveTarget(target, deps) {
|
||||
const { links, state } = deps
|
||||
const data = { ...target.data }
|
||||
|
||||
// `owner` — one account, one user. An unlinked account is nobody to notify,
|
||||
// which is a normal outcome and not an error: most game accounts on most shards
|
||||
// have never been linked.
|
||||
//
|
||||
// **`userId`, not `user_id`.** The model's `toSafe` camel-cases the row on the
|
||||
// way out, so reading the column name silently makes EVERY owner-audienced
|
||||
// trigger resolve to nobody — indistinguishable, from here and from the logs,
|
||||
// from the ordinary unlinked-account case above. `shardPush.js` is the
|
||||
// precedent this file follows and it reads `owner.userId`.
|
||||
if (target.ownerAccount) {
|
||||
const link = await links.getByAccount(target.ownerAccount)
|
||||
if (!link || link.userId == null) return null
|
||||
return { data, ownerUserId: Number(link.userId) }
|
||||
}
|
||||
|
||||
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
|
||||
// The owner comes from this module's registry mirror — and this read has to
|
||||
// happen before `applyStateChange` drops the row, which is why ingest calls the
|
||||
// engagement fan-out ahead of the state write.
|
||||
if (target.houseSerial) {
|
||||
const houses = await state.listHouses()
|
||||
const house = houses.find((h) => String(h.serial) === target.houseSerial)
|
||||
if (!house || !house.ownerAcct) return null
|
||||
const link = await links.getByAccount(house.ownerAcct)
|
||||
if (!link || link.userId == null) return null
|
||||
if (house.name) data.houseName = house.name
|
||||
if (house.region) data.region = house.region
|
||||
return { data, ownerUserId: Number(link.userId) }
|
||||
}
|
||||
|
||||
// `members` — the guild's roster, resolved to website users through
|
||||
// `shard_account_links` rather than through the roster's mirrored `web_id`.
|
||||
// The mirror is a copy of what the wire said; the links table is the answer.
|
||||
if (target.guildId != null) {
|
||||
const accounts = await state.listGuildMemberAccounts(target.guildId)
|
||||
const userIds = await links.userIdsForAccounts(accounts)
|
||||
if (!userIds.length) return null
|
||||
|
||||
// Fill in the two names the frames do not carry, from the board mirror.
|
||||
if (!data.guildName || !data.abbreviation) {
|
||||
const guilds = await state.listGuilds()
|
||||
const guild = guilds.find((g) => String(g.id) === String(target.guildId))
|
||||
if (guild) {
|
||||
if (!data.guildName) data.guildName = guild.name || `guild ${target.guildId}`
|
||||
if (guild.abbr && data.abbreviation === undefined) data.abbreviation = guild.abbr
|
||||
}
|
||||
}
|
||||
if (!data.guildName) data.guildName = `guild ${target.guildId}`
|
||||
|
||||
// Who left, from the roster mirror — the departing member's row is still
|
||||
// there, because `guild.leave`'s state write has not run yet.
|
||||
if (target.memberSerial) {
|
||||
const members = await state.listGuildMembers(target.guildId)
|
||||
const gone = members.find((m) => String(m.serial) === target.memberSerial)
|
||||
if (gone && gone.name) data.memberName = gone.name
|
||||
// The in-universe body's spine (Phase 11b decision 8). Built HERE and not
|
||||
// in the mapper because the name comes from the roster mirror, which the
|
||||
// mapper cannot read — and a herald's notice that names nobody is worse
|
||||
// than one that says "a member".
|
||||
if (data.guildName !== undefined) data.memberLabel = data.memberName || 'A member'
|
||||
}
|
||||
return { data, recipientUserIds: userIds }
|
||||
}
|
||||
|
||||
// Everything else — `subscribers`, `staff`, `admin` — has no per-event
|
||||
// audience to resolve. The rule's audience is the whole answer.
|
||||
return { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan one shard event out to the engagement engine.
|
||||
*
|
||||
* Never throws. Called fire-and-forget from `shardIngest.ingest`, beside the SSE
|
||||
* broadcast and the push dispatch, and held to the same promise all three make:
|
||||
* a slow or failing notification path must never delay or fail ingest.
|
||||
*/
|
||||
async function fromShardEvent(event, deps = {}) {
|
||||
const d = {
|
||||
links: deps.shardLinks || shardLinks,
|
||||
state: deps.shardState || shardState,
|
||||
emit: deps.emit || core.events.emit,
|
||||
tracker: deps.tracker || defaultTracker,
|
||||
}
|
||||
|
||||
for (const target of mapShardEvent(event, d.tracker)) {
|
||||
try {
|
||||
const resolved = await resolveTarget(target, d)
|
||||
// Nobody to tell. Not an error and deliberately not logged at warn: an
|
||||
// unlinked house owner is the common case on every shard.
|
||||
if (!resolved) continue
|
||||
d.emit(target.triggerId, {
|
||||
data: resolved.data,
|
||||
...(resolved.ownerUserId ? { ownerUserId: resolved.ownerUserId } : {}),
|
||||
...(resolved.recipientUserIds ? { recipientUserIds: resolved.recipientUserIds } : {}),
|
||||
...(target.dedupeKey ? { dedupeKey: target.dedupeKey } : {}),
|
||||
occurredAt: Number.isFinite(event.t) ? new Date(event.t) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('engagement target failed', { triggerId: target.triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fromShardEvent,
|
||||
mapShardEvent,
|
||||
createTracker,
|
||||
reset,
|
||||
VENDOR_WARN_HOURS,
|
||||
GOLD_THRESHOLDS,
|
||||
ACCOUNT_THRESHOLDS,
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { settings: settingsModel } = require('../core')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const shardPush = require('./shardPush')
|
||||
const shardEngagement = require('./shardEngagement')
|
||||
const defaultLog = require('../core').logger('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
@@ -62,6 +63,11 @@ const LOGGED_KINDS = new Set([
|
||||
const state = { bootId: null }
|
||||
function reset() {
|
||||
state.bootId = null
|
||||
// The engagement mapper's transition/threshold tracker is per-process state of
|
||||
// exactly the same kind as `bootId`, so it is reset by the same call. A test
|
||||
// that reset one and not the other would see a champion spawn that started in
|
||||
// the previous test.
|
||||
shardEngagement.reset()
|
||||
}
|
||||
|
||||
// Should this event be written to the append-only log?
|
||||
@@ -169,8 +175,14 @@ async function applyStateChange(event, deps) {
|
||||
name: event.name,
|
||||
ownerSerial: event.ownerSerial,
|
||||
ownerAcct: event.ownerAcct,
|
||||
// Protocol 5. `ownerName` used to arrive only on house.update, so a house
|
||||
// that had decayed but never been swept into the registry named an account
|
||||
// and no character. It rides house.decay now, which is the frame the IDOC
|
||||
// page is actually built from.
|
||||
ownerName: event.ownerName,
|
||||
builtOn: event.builtOn,
|
||||
lastRefreshed: event.lastRefreshed,
|
||||
schedule: event.schedule,
|
||||
})
|
||||
return
|
||||
case 'champ.update':
|
||||
@@ -279,6 +291,7 @@ function resolveDeps(deps) {
|
||||
settings: deps.settings || settingsModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
@@ -294,6 +307,34 @@ async function ingest(event, deps = {}) {
|
||||
let stored = false
|
||||
let logged = false
|
||||
|
||||
// **The engagement fan-out runs BEFORE the state write, and that ordering is
|
||||
// load-bearing rather than incidental** (ENGAGEMENT.md Phase 11). Three of the
|
||||
// mappings read a row that `applyStateChange` is about to delete or replace:
|
||||
//
|
||||
// • `account.unlinked` drops the `shard_account_links` row — the row that
|
||||
// turns the account into the one person who needs to be told it was
|
||||
// unlinked. Resolving afterwards finds nobody, every time.
|
||||
// • `house.remove` drops the house, whose stored `ownerAcct` is the only place
|
||||
// the owner of a collapsed house is named (the frame carries a serial alone).
|
||||
// • `guild.leave` / `guild.remove` need the roster and the board mirror to
|
||||
// name who left and which guild it was.
|
||||
//
|
||||
// Awaited, unlike the broadcast and the push tickle below, and this is the one
|
||||
// place this file waits on a notification path. It has to: the whole point is
|
||||
// that the read happens first, and a fire-and-forget promise would race the
|
||||
// DELETE it is trying to precede. `fromShardEvent` never throws and never opens
|
||||
// a socket — it resolves ids and hands the engine an envelope, which does its
|
||||
// own work off the caller's stack (`emit` is deliberately not awaited inside).
|
||||
// Backfilled frames are excluded for the same reason the broadcast is: a
|
||||
// reconnect replay must not re-notify anyone about events from hours ago.
|
||||
if (!deps.fromBackfill) {
|
||||
try {
|
||||
await d.engagement(event)
|
||||
} catch (err) {
|
||||
d.log.warn('engagement fan-out failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await applyStateChange(event, d)
|
||||
} catch (err) {
|
||||
|
||||
@@ -91,9 +91,22 @@ const FEATURES = {
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
|
||||
// actually know it, when the house collapses. It defaults to `anonymous` because
|
||||
// that is what the public IDOC page is FOR: the countdown is the content, and a
|
||||
// house at IDOC is already announced in game. It is listed rather than left
|
||||
// unconfigurable so a shard that considers a precise collapse time an unfair
|
||||
// advantage can raise it, and it is one NESTED key so raising it hides the whole
|
||||
// schedule rather than three of its four parts.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
fields: {
|
||||
owner: 'staff',
|
||||
ownerName: 'staff',
|
||||
ownerSerial: 'staff',
|
||||
price: 'staff',
|
||||
schedule: 'anonymous',
|
||||
},
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
@@ -124,9 +137,25 @@ const FEATURES = {
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
|
||||
// defaults. The shop name, the owner and the location are already visible to any
|
||||
// player through the stock in-game Vendor Search gump, which is the whole argument
|
||||
// for publishing them. A vendor's held gold, daily charge and dismissal date are
|
||||
// not: in game they are visible to the OWNER, on that vendor's own gump. Publishing
|
||||
// them anonymously would be a genuinely new disclosure and a targeting aid — it
|
||||
// says which shops are about to be abandoned and how much coin is sitting in each.
|
||||
// So it defaults to `admin`, the only default here that does not reproduce prior
|
||||
// behaviour, because there is no prior behaviour to reproduce.
|
||||
//
|
||||
// Nested for the same reason `location` is: one rule covers all seven parts.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
fields: {
|
||||
ownerName: 'anonymous',
|
||||
ownerSerial: 'anonymous',
|
||||
location: 'anonymous',
|
||||
fees: 'admin',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,6 +206,13 @@ const KIND_FEATURE = new Map(
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
|
||||
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
|
||||
// to admin-only, which is the right answer for a frame that carries an IP address
|
||||
// and says whether a password was accepted — the same reasoning that keeps
|
||||
// house.update and account.login.attempt off this map. Adding it would mean
|
||||
// choosing a feature an admin could then widen, and there is no rung below admin
|
||||
// this frame belongs on.
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
|
||||
Reference in New Issue
Block a user