Merge pull request 'fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger' (#24) from fix/engagement-live-walk-uo into edge
Reviewed-on: #24
This commit is contained in:
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 }
|
||||||
@@ -126,6 +126,8 @@ const inapp = (key, name, triggerId, title, body, action, url) => ({
|
|||||||
|
|
||||||
// ── The sixteen in-universe bodies ─────────────────────────────────────────
|
// ── The sixteen in-universe bodies ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const { PATHS } = require('./clientPaths')
|
||||||
|
|
||||||
const TEMPLATES = [
|
const TEMPLATES = [
|
||||||
// ── The Office of Deeds — houses ────────────────────────────────────────
|
// ── The Office of Deeds — houses ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -185,7 +187,39 @@ const TEMPLATES = [
|
|||||||
'Thy house has fallen',
|
'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.',
|
'{{houseLabel}} has fallen, and the deed is struck from the ledger. The ground is open to any who would build there.',
|
||||||
'Review thy holdings',
|
'Review thy holdings',
|
||||||
'/player/uo/houses',
|
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 ──────────────────────────────────────
|
// ── The Merchants' Guild — vendors ──────────────────────────────────────
|
||||||
@@ -242,7 +276,7 @@ const TEMPLATES = [
|
|||||||
'A sale at thy shop',
|
'A sale at thy shop',
|
||||||
'{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.',
|
'{{itemLine}} sold for {{price}} gold. The takings are held by thy keeper until thou callest for them.',
|
||||||
'Open the market',
|
'Open the market',
|
||||||
'/shard/market',
|
PATHS.market,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── A guild herald ──────────────────────────────────────────────────────
|
// ── A guild herald ──────────────────────────────────────────────────────
|
||||||
@@ -292,7 +326,7 @@ const TEMPLATES = [
|
|||||||
'{{guildName}} is dissolved',
|
'{{guildName}} is dissolved',
|
||||||
'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.',
|
'The charter is void and the rolls are closed. Those who wore its colours wear them no longer.',
|
||||||
'Open the shard',
|
'Open the shard',
|
||||||
'/shard',
|
PATHS.shard,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Lord Blackthorn's court — the crown's business ──────────────────────
|
// ── Lord Blackthorn's court — the crown's business ──────────────────────
|
||||||
@@ -366,7 +400,7 @@ const TEMPLATES = [
|
|||||||
text('p1',
|
text('p1',
|
||||||
'{{phaseLabel}} in {{city}}.{{candidateNote}}'),
|
'{{phaseLabel}} in {{city}}.{{candidateNote}}'),
|
||||||
text('p2',
|
text('p2',
|
||||||
'Those who hold the loyalty of the city may speak. Attend before {{autoPickAt}}: '
|
'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 '
|
+ '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.'),
|
+ 'complaint from any who could have spoken and did not.'),
|
||||||
button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
|
button('cta', 'Attend the city', '{{governorsUrl}}', 'The offices of the realm are recorded here:'),
|
||||||
@@ -377,7 +411,7 @@ const TEMPLATES = [
|
|||||||
'Election — the ballot opens (in-app)',
|
'Election — the ballot opens (in-app)',
|
||||||
'uo.election.opened',
|
'uo.election.opened',
|
||||||
'{{phaseLabel}} in {{city}}',
|
'{{phaseLabel}} in {{city}}',
|
||||||
'Attend before {{autoPickAt}} — after that hour the matter is decided without thee.{{candidateNote}}',
|
'Attend before {{autoPickWhen}} — after that hour the matter is decided without thee.{{candidateNote}}',
|
||||||
'Attend the city',
|
'Attend the city',
|
||||||
'{{governorsUrl}}',
|
'{{governorsUrl}}',
|
||||||
),
|
),
|
||||||
@@ -446,7 +480,7 @@ const TEMPLATES = [
|
|||||||
+ '{{cap}} is the whole of it; there is no further mark to reach.'),
|
+ '{{cap}} is the whole of it; there is no further mark to reach.'),
|
||||||
text('p2',
|
text('p2',
|
||||||
'What thou dost with it is thine own affair. The guild has taught thee what it knows.'),
|
'What thou dost with it is thine own affair. The guild has taught thee what it knows.'),
|
||||||
button('cta', 'Read thy character', '/player/uo/characters'),
|
button('cta', 'Read thy character', PATHS.characters),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
inapp(
|
inapp(
|
||||||
@@ -456,7 +490,7 @@ const TEMPLATES = [
|
|||||||
'{{characterName}} has mastered {{skill}}',
|
'{{characterName}} has mastered {{skill}}',
|
||||||
'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.',
|
'Thou hast carried {{skill}} as far as it will be carried — {{cap}} is the whole of it.',
|
||||||
'Read thy character',
|
'Read thy character',
|
||||||
'/player/uo/characters',
|
PATHS.characters,
|
||||||
),
|
),
|
||||||
|
|
||||||
email(
|
email(
|
||||||
@@ -469,7 +503,7 @@ const TEMPLATES = [
|
|||||||
text('p1',
|
text('p1',
|
||||||
'{{characterName}} has seen {{quest}} through to its end. It is written down, which is '
|
'{{characterName}} has seen {{quest}} through to its end. It is written down, which is '
|
||||||
+ 'more than most who set out on it can say.'),
|
+ 'more than most who set out on it can say.'),
|
||||||
button('cta', 'Read thy character', '/player/uo/characters'),
|
button('cta', 'Read thy character', PATHS.characters),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
inapp(
|
inapp(
|
||||||
@@ -479,7 +513,7 @@ const TEMPLATES = [
|
|||||||
'{{quest}} — concluded',
|
'{{quest}} — concluded',
|
||||||
'{{characterName}} has seen {{quest}} through to its end. It is written down.',
|
'{{characterName}} has seen {{quest}} through to its end. It is written down.',
|
||||||
'Read thy character',
|
'Read thy character',
|
||||||
'/player/uo/characters',
|
PATHS.characters,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── The Chronicler of the Dead ──────────────────────────────────────────
|
// ── The Chronicler of the Dead ──────────────────────────────────────────
|
||||||
@@ -508,7 +542,7 @@ const TEMPLATES = [
|
|||||||
'{{characterName}} has fallen',
|
'{{characterName}} has fallen',
|
||||||
'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.',
|
'An entry is made in the Chronicle: {{characterName}} has fallen{{slainBy}}.',
|
||||||
'Read thy character',
|
'Read thy character',
|
||||||
'/player/uo/characters',
|
PATHS.characters,
|
||||||
),
|
),
|
||||||
|
|
||||||
email(
|
email(
|
||||||
@@ -532,7 +566,7 @@ const TEMPLATES = [
|
|||||||
'{{characterName}} was murdered',
|
'{{characterName}} was murdered',
|
||||||
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.',
|
'An entry is made, and it is not an accident: {{characterName}} was slain{{slainBy}}.',
|
||||||
'Read thy character',
|
'Read thy character',
|
||||||
'/player/uo/characters',
|
PATHS.characters,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── The keeper of the rolls ─────────────────────────────────────────────
|
// ── The keeper of the rolls ─────────────────────────────────────────────
|
||||||
@@ -547,7 +581,7 @@ const TEMPLATES = [
|
|||||||
'The roll of {{boardLabel}} is amended. {{standingLine}}'),
|
'The roll of {{boardLabel}} is amended. {{standingLine}}'),
|
||||||
text('p2',
|
text('p2',
|
||||||
'A roll is only ever the state of a thing on the day it was read.'),
|
'A roll is only ever the state of a thing on the day it was read.'),
|
||||||
button('cta', 'Read the roll', '/shard/points'),
|
button('cta', 'Read the roll', PATHS.leaderboards),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
inapp(
|
inapp(
|
||||||
@@ -557,7 +591,7 @@ const TEMPLATES = [
|
|||||||
'A new name heads {{boardLabel}}',
|
'A new name heads {{boardLabel}}',
|
||||||
'The roll of {{boardLabel}} is amended. {{standingLine}}',
|
'The roll of {{boardLabel}} is amended. {{standingLine}}',
|
||||||
'Read the roll',
|
'Read the roll',
|
||||||
'/shard/points',
|
PATHS.leaderboards,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -617,7 +651,30 @@ const RULES = [
|
|||||||
// seconds; mailing them anyway is how a warning system teaches people to
|
// seconds; mailing them anyway is how a warning system teaches people to
|
||||||
// ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this.
|
// ignore it. Phase 4a's `delay_seconds` + `cancel_on` is precisely this.
|
||||||
delay_seconds: 900,
|
delay_seconds: 900,
|
||||||
cancel_on: ['uo.house.collapsed'],
|
// **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,
|
max_sends_per_hour: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const OWNED_ASSET = [
|
|||||||
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
|
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',
|
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
|
||||||
description: 'When the house was last refreshed.' },
|
description: 'When the house was last refreshed.' },
|
||||||
{ name: 'houseUrl', type: 'url', required: false, example: '/shard/houses',
|
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||||||
description: 'Site-relative path to the IDOC page.' },
|
description: 'Site-relative path to the IDOC page.' },
|
||||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
{ 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.' },
|
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||||
@@ -156,6 +156,47 @@ const OWNED_ASSET = [
|
|||||||
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
|
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',
|
id: 'uo.vendor.expiring',
|
||||||
label: 'Your vendor is about to be dismissed',
|
label: 'Your vendor is about to be dismissed',
|
||||||
@@ -189,7 +230,7 @@ const OWNED_ASSET = [
|
|||||||
description: 'What each tick deducts.' },
|
description: 'What each tick deducts.' },
|
||||||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
|
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
|
||||||
description: 'Where the shop stands, already formatted for reading.' },
|
description: 'Where the shop stands, already formatted for reading.' },
|
||||||
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
|
{ name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
|
||||||
description: 'Site-relative path to the market page.' },
|
description: 'Site-relative path to the market page.' },
|
||||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
{ 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.' },
|
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||||
@@ -388,7 +429,7 @@ const SOCIAL_CIVIC = [
|
|||||||
// is optional because a member the sweep never saw has no row there.
|
// is optional because a member the sweep never saw has no row there.
|
||||||
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
|
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
|
||||||
description: 'Who left, when the roster mirror still knows their name.' },
|
description: 'Who left, when the roster mirror still knows their name.' },
|
||||||
{ name: 'guildUrl', type: 'url', required: false, example: '/shard/guilds',
|
{ name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
|
||||||
description: 'Site-relative path to the guilds page.' },
|
description: 'Site-relative path to the guilds page.' },
|
||||||
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
|
{ 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.' },
|
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
|
||||||
@@ -440,7 +481,7 @@ const SOCIAL_CIVIC = [
|
|||||||
description: 'Your character\'s name, as the city knows it.' },
|
description: 'Your character\'s name, as the city knows it.' },
|
||||||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||||
description: 'Who held the seat before, when there was someone.' },
|
description: 'Who held the seat before, when there was someone.' },
|
||||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||||
description: 'Site-relative path to the governors page.' },
|
description: 'Site-relative path to the governors page.' },
|
||||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
{ 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.' },
|
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||||
@@ -462,7 +503,7 @@ const SOCIAL_CIVIC = [
|
|||||||
description: 'The new governor.' },
|
description: 'The new governor.' },
|
||||||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||||
description: 'Who held the seat before, when there was someone.' },
|
description: 'Who held the seat before, when there was someone.' },
|
||||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||||
description: 'Site-relative path to the governors page.' },
|
description: 'Site-relative path to the governors page.' },
|
||||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
{ 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.' },
|
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||||
@@ -489,9 +530,16 @@ const SOCIAL_CIVIC = [
|
|||||||
description: 'Which phase opened: nominate or vote.' },
|
description: 'Which phase opened: nominate or vote.' },
|
||||||
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
|
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
|
||||||
description: 'When the game decides for itself — the real deadline.' },
|
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,
|
{ name: 'candidates', type: 'int', required: false, example: 3,
|
||||||
description: 'How many candidates stand.' },
|
description: 'How many candidates stand.' },
|
||||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
|
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||||
description: 'Site-relative path to the governors page.' },
|
description: 'Site-relative path to the governors page.' },
|
||||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
||||||
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
||||||
@@ -522,7 +570,7 @@ const COME_ONLINE = [
|
|||||||
description: 'champion, mini or sea.' },
|
description: 'champion, mini or sea.' },
|
||||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||||
description: 'Where, already formatted for reading.' },
|
description: 'Where, already formatted for reading.' },
|
||||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||||
description: 'Site-relative path to the champions page.' },
|
description: 'Site-relative path to the champions page.' },
|
||||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
{ 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.' },
|
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||||
@@ -546,7 +594,7 @@ const COME_ONLINE = [
|
|||||||
description: 'The boss, when the shard names it.' },
|
description: 'The boss, when the shard names it.' },
|
||||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||||
description: 'Where, already formatted for reading.' },
|
description: 'Where, already formatted for reading.' },
|
||||||
{ name: 'champsUrl', type: 'url', required: false, example: '/shard/champs',
|
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||||
description: 'Site-relative path to the champions page.' },
|
description: 'Site-relative path to the champions page.' },
|
||||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
{ 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.' },
|
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||||
@@ -569,7 +617,7 @@ const COME_ONLINE = [
|
|||||||
variables: [
|
variables: [
|
||||||
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||||||
description: 'What the shard calls itself.' },
|
description: 'What the shard calls itself.' },
|
||||||
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
|
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||||||
description: 'Site-relative path to the shard status page.' },
|
description: 'Site-relative path to the shard status page.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -586,7 +634,7 @@ const COME_ONLINE = [
|
|||||||
description: 'What the shard calls itself.' },
|
description: 'What the shard calls itself.' },
|
||||||
{ name: 'clean', type: 'boolean', required: false, example: true,
|
{ name: 'clean', type: 'boolean', required: false, example: true,
|
||||||
description: 'Whether it was a clean shutdown rather than a crash.' },
|
description: 'Whether it was a clean shutdown rather than a crash.' },
|
||||||
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
|
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||||||
description: 'Site-relative path to the shard status page.' },
|
description: 'Site-relative path to the shard status page.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -655,7 +703,7 @@ const STAFF_FACING = [
|
|||||||
description: 'What they wrote.' },
|
description: 'What they wrote.' },
|
||||||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
|
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
|
||||||
description: 'Where they are, already formatted for reading.' },
|
description: 'Where they are, already formatted for reading.' },
|
||||||
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/shard',
|
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
|
||||||
description: 'Site-relative path to the help-page queue.' },
|
description: 'Site-relative path to the help-page queue.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -734,7 +782,7 @@ const OPERATOR_FACING = [
|
|||||||
description: 'The threshold it crossed.' },
|
description: 'The threshold it crossed.' },
|
||||||
{ name: 'direction', type: 'string', required: true, example: 'up',
|
{ name: 'direction', type: 'string', required: true, example: 'up',
|
||||||
description: 'up or down.' },
|
description: 'up or down.' },
|
||||||
{ name: 'economyUrl', type: 'url', required: false, example: '/shard',
|
{ name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
|
||||||
description: 'Site-relative path to the shard status page.' },
|
description: 'Site-relative path to the shard status page.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ test('every template key a rule names exists — its own or core\'s', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the sixteen in-universe families have both channels; the nine plain ones have neither', () => {
|
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))
|
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||||
let bespoke = 0
|
let bespoke = 0
|
||||||
for (const r of seeds.RULES) {
|
for (const r of seeds.RULES) {
|
||||||
@@ -96,8 +96,8 @@ test('the sixteen in-universe families have both channels; the nine plain ones h
|
|||||||
// letter from anybody.
|
// letter from anybody.
|
||||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||||
}
|
}
|
||||||
assert.equal(bespoke, 16)
|
assert.equal(bespoke, 17)
|
||||||
assert.equal(seeds.TEMPLATES.length, 32)
|
assert.equal(seeds.TEMPLATES.length, 34)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||||
@@ -141,12 +141,23 @@ const LABELLED = [
|
|||||||
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
|
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
|
||||||
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
|
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
|
||||||
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
|
{ 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', () => {
|
test('every label a body builds a sentence around is supplied by the mapper', () => {
|
||||||
for (const [triggerId, labels, frame] of LABELLED) {
|
for (const [triggerId, labels, frame] of LABELLED) {
|
||||||
// A first frame is never a transition, so the upsert kinds need a prior one.
|
// 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' }] }, tracker)
|
engagement.mapShardEvent(
|
||||||
|
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
|
||||||
|
tracker,
|
||||||
|
)
|
||||||
const targets = engagement.mapShardEvent(frame, tracker)
|
const targets = engagement.mapShardEvent(frame, tracker)
|
||||||
const target = targets.find((t) => t.triggerId === triggerId)
|
const target = targets.find((t) => t.triggerId === triggerId)
|
||||||
assert.ok(target, `${triggerId} fired`)
|
assert.ok(target, `${triggerId} fired`)
|
||||||
@@ -227,5 +238,5 @@ test('one rule group, and appending to it later would reach fresh installs only'
|
|||||||
// rule has to edit a test whose name says what appending costs.
|
// rule has to edit a test whose name says what appending costs.
|
||||||
assert.equal(seeds.RULE_GROUPS.length, 1)
|
assert.equal(seeds.RULE_GROUPS.length, 1)
|
||||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 25)
|
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const assert = require('node:assert/strict')
|
|||||||
|
|
||||||
const engagement = require('../utils/shardEngagement')
|
const engagement = require('../utils/shardEngagement')
|
||||||
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
||||||
|
const { PATHS } = require('../config/clientPaths')
|
||||||
|
|
||||||
let tracker
|
let tracker
|
||||||
beforeEach(() => { tracker = engagement.createTracker() })
|
beforeEach(() => { tracker = engagement.createTracker() })
|
||||||
@@ -29,7 +30,7 @@ const one = (event) => {
|
|||||||
// ── The catalogue itself ───────────────────────────────────────────────────
|
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||||
|
|
||||||
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
||||||
assert.equal(TRIGGERS.length, 25)
|
assert.equal(TRIGGERS.length, 26)
|
||||||
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
// 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
|
// 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.
|
// a decision should have to edit a test that says so.
|
||||||
@@ -73,6 +74,72 @@ test('a url variable is site-RELATIVE — an absolute one ends up in an href', (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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.
|
// The declaration that the whole ceiling lattice exists for.
|
||||||
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
|
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
|
||||||
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
|
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
|
||||||
@@ -107,12 +174,29 @@ test('a late decay stage warns the owner; an early one says nothing', () => {
|
|||||||
assert.equal(t.ownerAccount, 'seed_002')
|
assert.equal(t.ownerAccount, 'seed_002')
|
||||||
assert.equal(t.data.stage, 'Greatly')
|
assert.equal(t.data.stage, 'Greatly')
|
||||||
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
|
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
|
||||||
// A house being refreshed is the normal case. Mailing it would make the
|
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
|
||||||
// warning worthless.
|
// not news, and mailing it would make the warning worthless.
|
||||||
assert.deepEqual(ids({ ...DECAY, to: 'LikeNew' }), [])
|
|
||||||
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
|
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', () => {
|
test('the v5 schedule rides along when present and is simply absent when not', () => {
|
||||||
const withSchedule = one({
|
const withSchedule = one({
|
||||||
...DECAY,
|
...DECAY,
|
||||||
@@ -179,10 +263,13 @@ 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
|
// `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
|
// check a vendor inside the window mails its owner every time somebody
|
||||||
// reprices a longsword.
|
// reprices a longsword.
|
||||||
const first = one(listing(FEES(20)))
|
// 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.triggerId, 'uo.vendor.expiring')
|
||||||
assert.equal(first.ownerAccount, 'darrow_acct')
|
assert.equal(first.ownerAccount, 'darrow_acct')
|
||||||
assert.equal(first.data.hoursRemaining, 19) // floor of 20h minus the tick spent here
|
assert.equal(first.data.hoursRemaining, 20)
|
||||||
assert.deepEqual(ids(listing(FEES(19))), [])
|
assert.deepEqual(ids(listing(FEES(19))), [])
|
||||||
assert.deepEqual(ids(listing(FEES(18))), [])
|
assert.deepEqual(ids(listing(FEES(18))), [])
|
||||||
})
|
})
|
||||||
@@ -448,6 +535,26 @@ test('an unmapped kind and a malformed frame both produce nothing', () => {
|
|||||||
|
|
||||||
// ── Resolution: the half that reaches the database ─────────────────────────
|
// ── 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 = {}) {
|
function deps(over = {}) {
|
||||||
const emitted = []
|
const emitted = []
|
||||||
return {
|
return {
|
||||||
@@ -455,7 +562,11 @@ function deps(over = {}) {
|
|||||||
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
||||||
tracker,
|
tracker,
|
||||||
shardLinks: {
|
shardLinks: {
|
||||||
getByAccount: async (acct) => (acct === 'seed_002' ? { account: acct, user_id: 7 } : null),
|
// 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] : []),
|
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
|
||||||
...over.shardLinks,
|
...over.shardLinks,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
const shardState = require('../model/shardState/shardState.model')
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
const { TRIGGER_IDS } = require('../config/shardTriggers')
|
const { TRIGGER_IDS } = require('../config/shardTriggers')
|
||||||
|
const { PATHS, guildPath } = require('../config/clientPaths')
|
||||||
const core = require('../core')
|
const core = require('../core')
|
||||||
|
|
||||||
const log = core.logger('shard-engagement')
|
const log = core.logger('shard-engagement')
|
||||||
@@ -196,6 +197,35 @@ const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] ||
|
|||||||
// "On hand: gold. Charged each period: gold." — which is what the render sweep
|
// "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
|
// 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.
|
// 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 detailLine = (parts) => {
|
||||||
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||||
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
|
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
|
||||||
@@ -239,6 +269,45 @@ const MAPPERS = {
|
|||||||
})
|
})
|
||||||
return
|
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
|
if (!['FAIRLY', 'GREATLY', 'IDOC'].includes(to)) return
|
||||||
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
|
const schedule = ev.schedule && typeof ev.schedule === 'object' ? ev.schedule : {}
|
||||||
out.push({
|
out.push({
|
||||||
@@ -246,6 +315,7 @@ const MAPPERS = {
|
|||||||
ownerAccount: ev.ownerAcct,
|
ownerAccount: ev.ownerAcct,
|
||||||
data: defined({
|
data: defined({
|
||||||
houseSerial: serial,
|
houseSerial: serial,
|
||||||
|
houseUrl: PATHS.houses,
|
||||||
houseName: decayName(ev),
|
houseName: decayName(ev),
|
||||||
stage: ev.to,
|
stage: ev.to,
|
||||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||||
@@ -308,6 +378,7 @@ const MAPPERS = {
|
|||||||
ownerAccount: ev.ownerAcct,
|
ownerAccount: ev.ownerAcct,
|
||||||
data: defined({
|
data: defined({
|
||||||
vendorSerial: serial,
|
vendorSerial: serial,
|
||||||
|
marketUrl: PATHS.market,
|
||||||
shopName: ev.shopName || undefined,
|
shopName: ev.shopName || undefined,
|
||||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||||
dismissalAt: fees.dismissalAt,
|
dismissalAt: fees.dismissalAt,
|
||||||
@@ -322,7 +393,7 @@ const MAPPERS = {
|
|||||||
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
|
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
|
||||||
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
|
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
|
||||||
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
|
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
|
||||||
['Dismissal', fees.dismissalAt],
|
['Dismissal', readableTime(fees.dismissalAt)],
|
||||||
['Standing at', place(ev)],
|
['Standing at', place(ev)],
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
@@ -470,7 +541,7 @@ const MAPPERS = {
|
|||||||
// already left, so there is nothing for the shard to attribute. The name is
|
// already left, so there is nothing for the shard to attribute. The name is
|
||||||
// looked up from the roster mirror in `dispatch`.
|
// looked up from the roster mirror in `dispatch`.
|
||||||
memberSerial: ev.who == null ? null : String(ev.who),
|
memberSerial: ev.who == null ? null : String(ev.who),
|
||||||
data: defined({ guildName: ev.name || `guild ${ev.id}` }),
|
data: defined({ guildUrl: guildPath(ev.id), guildName: ev.name || `guild ${ev.id}` }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -504,6 +575,7 @@ const MAPPERS = {
|
|||||||
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
||||||
const civic = defined({
|
const civic = defined({
|
||||||
city: String(city),
|
city: String(city),
|
||||||
|
governorsUrl: PATHS.governors,
|
||||||
governorName,
|
governorName,
|
||||||
previousGovernorName: undefined,
|
previousGovernorName: undefined,
|
||||||
inSuccessionTo: undefined,
|
inSuccessionTo: undefined,
|
||||||
@@ -543,9 +615,11 @@ const MAPPERS = {
|
|||||||
triggerId: 'uo.election.opened',
|
triggerId: 'uo.election.opened',
|
||||||
data: defined({
|
data: defined({
|
||||||
city: String(city),
|
city: String(city),
|
||||||
|
governorsUrl: PATHS.governors,
|
||||||
phase,
|
phase,
|
||||||
phaseLabel: phaseLabel(phase),
|
phaseLabel: phaseLabel(phase),
|
||||||
autoPickAt: ev.autoPickAt,
|
autoPickAt: ev.autoPickAt,
|
||||||
|
autoPickWhen: readableTime(ev.autoPickAt),
|
||||||
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
||||||
candidateNote: trailing(
|
candidateNote: trailing(
|
||||||
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
|
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
|
||||||
@@ -566,6 +640,7 @@ const MAPPERS = {
|
|||||||
|
|
||||||
const base = defined({
|
const base = defined({
|
||||||
spawnSerial: serial,
|
spawnSerial: serial,
|
||||||
|
champsUrl: PATHS.champs,
|
||||||
spawnName: ev.name || ev.type || 'a champion spawn',
|
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||||
category: ev.category || undefined,
|
category: ev.category || undefined,
|
||||||
location: place(ev),
|
location: place(ev),
|
||||||
@@ -604,20 +679,20 @@ const MAPPERS = {
|
|||||||
if (wasUp === true) return
|
if (wasUp === true) return
|
||||||
out.push({
|
out.push({
|
||||||
triggerId: 'uo.server.up',
|
triggerId: 'uo.server.up',
|
||||||
data: defined({ shardName: ev.shard || undefined }),
|
data: defined({ statusUrl: PATHS.shard, shardName: ev.shard || undefined }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
'server.shutdown': (ev, tracker, out) => {
|
'server.shutdown': (ev, tracker, out) => {
|
||||||
if (tracker.serverUp === false) return
|
if (tracker.serverUp === false) return
|
||||||
tracker.serverUp = false
|
tracker.serverUp = false
|
||||||
out.push({ triggerId: 'uo.server.down', data: { clean: true } })
|
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: true } })
|
||||||
},
|
},
|
||||||
|
|
||||||
'server.crashed': (ev, tracker, out) => {
|
'server.crashed': (ev, tracker, out) => {
|
||||||
if (tracker.serverUp === false) return
|
if (tracker.serverUp === false) return
|
||||||
tracker.serverUp = false
|
tracker.serverUp = false
|
||||||
out.push({ triggerId: 'uo.server.down', data: { clean: false } })
|
out.push({ triggerId: 'uo.server.down', data: { statusUrl: PATHS.shard, clean: false } })
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Leaderboard ────────────────────────────────────────────────────────
|
// ── Leaderboard ────────────────────────────────────────────────────────
|
||||||
@@ -659,6 +734,7 @@ const MAPPERS = {
|
|||||||
out.push({
|
out.push({
|
||||||
triggerId: 'uo.page.new',
|
triggerId: 'uo.page.new',
|
||||||
data: defined({
|
data: defined({
|
||||||
|
pagesUrl: PATHS.ops,
|
||||||
pageType: String(ev.type || 'Other'),
|
pageType: String(ev.type || 'Other'),
|
||||||
senderName: actorName(ev.sender),
|
senderName: actorName(ev.sender),
|
||||||
message: ev.message || undefined,
|
message: ev.message || undefined,
|
||||||
@@ -738,6 +814,7 @@ const MAPPERS = {
|
|||||||
out.push({
|
out.push({
|
||||||
triggerId: 'uo.economy.milestone',
|
triggerId: 'uo.economy.milestone',
|
||||||
data: defined({
|
data: defined({
|
||||||
|
economyUrl: PATHS.shard,
|
||||||
metric,
|
metric,
|
||||||
value: Math.round(value),
|
value: Math.round(value),
|
||||||
threshold: crossed,
|
threshold: crossed,
|
||||||
@@ -799,10 +876,16 @@ async function resolveTarget(target, deps) {
|
|||||||
// `owner` — one account, one user. An unlinked account is nobody to notify,
|
// `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
|
// which is a normal outcome and not an error: most game accounts on most shards
|
||||||
// have never been linked.
|
// 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) {
|
if (target.ownerAccount) {
|
||||||
const link = await links.getByAccount(target.ownerAccount)
|
const link = await links.getByAccount(target.ownerAccount)
|
||||||
if (!link || link.user_id == null) return null
|
if (!link || link.userId == null) return null
|
||||||
return { data, ownerUserId: Number(link.user_id) }
|
return { data, ownerUserId: Number(link.userId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
|
// `uo.house.collapsed` off `house.remove`, whose frame carries only a serial.
|
||||||
@@ -814,10 +897,10 @@ async function resolveTarget(target, deps) {
|
|||||||
const house = houses.find((h) => String(h.serial) === target.houseSerial)
|
const house = houses.find((h) => String(h.serial) === target.houseSerial)
|
||||||
if (!house || !house.ownerAcct) return null
|
if (!house || !house.ownerAcct) return null
|
||||||
const link = await links.getByAccount(house.ownerAcct)
|
const link = await links.getByAccount(house.ownerAcct)
|
||||||
if (!link || link.user_id == null) return null
|
if (!link || link.userId == null) return null
|
||||||
if (house.name) data.houseName = house.name
|
if (house.name) data.houseName = house.name
|
||||||
if (house.region) data.region = house.region
|
if (house.region) data.region = house.region
|
||||||
return { data, ownerUserId: Number(link.user_id) }
|
return { data, ownerUserId: Number(link.userId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// `members` — the guild's roster, resolved to website users through
|
// `members` — the guild's roster, resolved to website users through
|
||||||
|
|||||||
Reference in New Issue
Block a user