Files
Module-uo/server/config/shardTriggers.js
wtclaude 50a89b48e2
Some checks failed
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 22s
PR Checks / frozen-manifest (pull_request) Failing after -34s
feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
11a declared the triggers; this is the content behind them. Ships through
core's new api.registerEngagementSeeds (MODULE_API 1.9.0): 32 templates and 25
rules, every rule enabled = 0.

THE VOICE (decision 8). The game-powered families read from inside Britannia,
with a per-family in-fiction sender rather than one voice across all sixteen —
Lord Blackthorn's court writes about the crown's business (the seat, the ballot)
and nothing else, because a shard where Blackthorn writes to you personally about
a champion spawn is a shard where the letter about your governorship means
nothing. The Office of Deeds has houses, the Merchants' Guild vendors, a herald
guilds, the town crier champion spawns, a guildmaster skills and quests, the
Chronicler deaths, the keeper of the rolls leaderboards.

WHAT STAYS PLAIN (decision 9). Nine of the 25 point at core's notify.event /
inapp.event and author nothing, and the line is drawn where fiction costs
something real: 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 a moderator reading uo.cheat.detected at 2am wants a name, a rule and a
timestamp rather than a scroll. Both account-security triggers, server up/down,
and the five staff/admin-ceiling ones.

THE GOVERNOR'S LETTER (decision 10) — uo.governor.appointed, the 25th trigger.
§8.6 records that uo.points.rank_changed cannot address a person because top[]
names a mobile serial, and the same reasoning was silently assumed to cover the
governor. It does not: city.update's `governor` is written by BridgeJson.Actor(),
which emits serial, name, acct AND webId. The winner is addressable today with no
protocol change. It fires from the same frame, the same transition and the same
never-on-first-sight guard as uo.governor.elected, which stays exactly as
declared — the town's bulletin and the governor's letter are two triggers because
one trigger means one rule means one template, and they are not the same text.
An operator can run either alone.

PRESENTATIONAL FRAGMENTS, because a template has no conditionals by design and an
unset optional interpolates to the empty string. Phase 5a's `forWhom` precedent:
the ternary stays in the mapper and its result arrives as a declared optional.
Two shapes — a LABEL always has a value and carries a sentence's spine
(houseLabel falls back to a seal number); a TRAILING FRAGMENT may be empty and
leads with its own space, so `{{slainBy}}.` closes as "has fallen." either way.
Additive, so no version bump.

A render sweep over all 32 bodies, twice — once with every declared example and
once with required variables only — is what found these. Three defects it caught:
an optional `{{region}}` in a subject line ("A notice concerning thy house at ");
multi-optional ledger lines rendering "On hand:  gold. Charged each period:
gold." on a pre-v5 frame, now assembled in the mapper from the parts actually
present, the same argument place() already makes; and a leading trailing-fragment
opening a body with a stray space.

The labels stay `required: false` deliberately — a missing one must never REFUSE
an emit, since a dropped notification is worse than a cosmetic hole — so nothing
at runtime would notice a mapper that forgot one. engagementSeeds.test.js is what
notices.

524 module tests green; check:imports and check:bundle clean. check:swagger
reports STALE from CRLF alone and regenerates byte-identical — no route changed.

Refs docs ENGAGEMENT.md Phase 11b, decisions 8, 9, 10.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:02:15 -05:00

788 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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: '/shard/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.' },
],
},
{
id: 'uo.vendor.expiring',
label: 'Your vendor is about to be dismissed',
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
description: 'The shop\'s name.' },
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
// **The int an operator narrows with**, because `conditions.js` compares a
// declared variable against a LITERAL and has no relative-time operator:
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
// 24h`. So the hours are computed at emit and the operator writes
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
// on any price change.
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
{ name: 'funds', type: 'int', required: false, example: 8204,
description: 'Gold available to pay the fees.' },
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
description: 'Where the shop stands, already formatted for reading.' },
{ name: 'marketUrl', type: 'url', required: false, example: '/shard/market',
description: 'Site-relative path to the market page.' },
{ 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: '/shard/guilds',
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: '/shard/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: '/shard/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.' },
{ name: 'candidates', type: 'int', required: false, example: 3,
description: 'How many candidates stand.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/shard/governors',
description: 'Site-relative path to the governors page.' },
{ 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: '/shard/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: '/shard/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: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.server.down',
label: 'The shard went offline',
description: 'The game server shut down or crashed.',
kind: 'event',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true,
description: 'Whether it was a clean shutdown rather than a crash.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
]
// ── Leaderboard ────────────────────────────────────────────────────────────
const LEADERBOARD = [
{
id: 'uo.points.rank_changed',
label: 'A leaderboard top spot changed',
// §8.6 originally described this firing both ways — "you entered a top N" and
// "you were pushed out". The personal half is carved out: `points.board`'s
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
// leaderboard mail that reaches half the board reads as favouritism, so the
// board feed ships and the personal one waits for a serial→account map.
description: 'The top of a leaderboard changed hands.',
kind: 'event',
subjectKey: 'system',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
description: 'The points system. Also the cooldown subject.' },
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
description: 'Its display name, when the shard gives one.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
description: 'Who is first now.' },
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
description: 'Who was first before.' },
{ name: 'points', type: 'int', required: false, example: 29500,
description: 'The new leader\'s points.' },
{ 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/shard',
description: 'Site-relative path to the help-page queue.' },
],
},
{
id: 'uo.cheat.detected',
label: 'The cheat detector fired',
description: 'The shard\'s own speed-hack detector flagged a player.',
kind: 'event',
// **`staff`, and never `owner`.** This is the declaration the whole lattice
// was written for: under a flat "fewer people is narrower" ordering a
// `staff` ceiling would also permit `owner`, and the rule an operator would
// then be able to save mails the cheat report to the player who was detected.
subjectKey: 'characterName',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was flagged. Also the cooldown subject.' },
{ name: 'account', type: 'string', required: false, example: 'seed_000',
description: 'Their game account.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where they were connected from.' },
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
description: 'Which detector fired.' },
],
},
]
// ── Operator-facing ────────────────────────────────────────────────────────
//
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
// digest of what moderators did at `staff` would have sent it to the moderators.
// All three are digest-shaped by nature; none should ever be instant, which is a
// property of 11b's seeded rules rather than of these declarations.
const OPERATOR_FACING = [
{
id: 'uo.audit.staff_action',
label: 'A staff member acted in game',
description: 'A staff command, a property change, or a moderation action.',
kind: 'event',
subjectKey: 'staffName',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
{ name: 'action', type: 'string', required: true, example: 'set',
description: 'What kind of action: set, command, ban, kick, mute…' },
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
description: 'The action in one line, already formatted for reading.' },
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who or what it was applied to.' },
{ name: 'origin', type: 'string', required: false, example: 'in-game',
description: 'web or in-game — where the action was issued from.' },
],
},
{
id: 'uo.economy.milestone',
label: 'The economy crossed a threshold',
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
kind: 'event',
subjectKey: 'metric',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'metric', type: 'string', required: true, example: 'gold',
description: 'gold or accounts. Also the cooldown subject.' },
{ name: 'value', type: 'int', required: true, example: 1000000000,
description: 'The value that crossed.' },
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up',
description: 'up or down.' },
{ name: 'economyUrl', type: 'url', required: false, example: '/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.world.saved',
label: 'The world saved',
description: 'A world save completed, with the item and mobile counts it wrote.',
kind: 'event',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'items', type: 'int', required: false, example: 1482301,
description: 'Items written.' },
{ name: 'mobiles', type: 'int', required: false, example: 41022,
description: 'Mobiles written.' },
],
},
]
const TRIGGERS = [
...OWNED_ASSET,
...PASSIVE_INCOME,
...PERSONAL_SECURITY,
...PERSONAL_MILESTONE,
...SOCIAL_CIVIC,
...COME_ONLINE,
...LEADERBOARD,
...STAFF_FACING,
...OPERATOR_FACING,
]
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
// emit an id this file does not declare, so a typo there is a boot-time-visible
// mistake rather than a dropped event nobody notices.
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
module.exports = {
TRIGGERS,
TRIGGER_IDS,
OWNED_ASSET,
PASSIVE_INCOME,
PERSONAL_SECURITY,
PERSONAL_MILESTONE,
SOCIAL_CIVIC,
COME_ONLINE,
LEADERBOARD,
STAFF_FACING,
OPERATOR_FACING,
}