The website's half of protocol 6. Every event-driven write now carries the step's idempotency key, and `uo.broadcast` stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything including a 503 from a shard that was merely restarting, with a comment naming the line that would change when the wire could refuse a repeat. This is that line: it defers to `sidecarFailure`, the same helper its two siblings already used, so the hand-rolled variant that forced every outcome terminal is gone rather than re-tuned. One verb was less idempotent than its own id made it look. Both keyed verbs post under a run-scoped id and a repeat replaces — but `news.add` with `announce: true` makes the criers proclaim the title on every post, so a retry replaced the article silently and proclaimed it again. The key stops the second proclamation. `champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail it closed to admin), with `damagers` a nested `staff` field rule: the kill is public because a champion falling is what the board is for, the ranked roll of who was strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger — which is what makes it usable as an event PHASE CONDITION, since a condition is written over a trigger firing — and it carries `damagerCount`, never a damager name, because a trigger variable reaches mail an operator may address to every subscriber. Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped once under a settings guard, so appending a 27th entry would have reached fresh installs and nothing else. It also ships email+inapp and NOT push, and the comment says why — no trigger in this module is also a registered stream, so no engagement rule here can push. That is pre-existing in twenty rules and flagged rather than fixed; this one declines to be the twenty-first. Co-Authored-By: Claude <noreply@anthropic.com>
872 lines
46 KiB
JavaScript
872 lines
46 KiB
JavaScript
// ── module-uo's engagement triggers ────────────────────────────────────────
|
||
//
|
||
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
|
||
// file declares which shard events a player may get a content-free PUSH tickle
|
||
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
|
||
// may fire on, what a template may interpolate, and the widest audience an
|
||
// operator may ever give it.
|
||
//
|
||
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
|
||
// trigger id and a stream id live in the same space and an id has exactly one
|
||
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
|
||
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
|
||
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
|
||
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
|
||
// preferences and no push toggle, which is correct — `allStreams()` serves the
|
||
// stream facet only, so the shipped Android client's catalog is unchanged.
|
||
//
|
||
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
|
||
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
|
||
//
|
||
// • `uo.market.item_listed` — a saved SEARCH, not a trigger. Its audience is
|
||
// "users whose stored query matches this listing" and no per-user query store
|
||
// exists anywhere in the tree.
|
||
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
|
||
// guild IS a Team and this module is the Team provider, so `teamSync` emits
|
||
// on every roster reconcile; a second trigger would be two mails for one join.
|
||
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
|
||
// • `uo.link.requested` — no addressable recipient by construction (the account
|
||
// is not yet linked, which is the point of the event) and a ~5-minute TTL no
|
||
// channel can beat.
|
||
// • `uo.points.rank_changed`'s personal half — `points.board`'s `top[]` names a
|
||
// mobile SERIAL and `shard_account_links` is keyed by ACCOUNT. The board-change
|
||
// feed ships at `subscribers`; "you were pushed out" does not.
|
||
//
|
||
// **Three rules every declaration below obeys, all of them enforced at
|
||
// registration** (`registries.js`), so a mistake here is a boot failure rather
|
||
// than a defect discovered in someone's mailbox:
|
||
//
|
||
// 1. **`ceiling` is required and there is no default.** It is the widest
|
||
// audience a rule may ever be given (G24), re-checked at save AND at send.
|
||
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
|
||
// cheat report to the player who was detected, and `staff` is the answer.
|
||
// 2. **Every variable carries an `example`.** It is what the template editor
|
||
// previews and test-sends with; without one, testing a template needs a live
|
||
// game event, which is how template systems ship untested (§4.3 property 3).
|
||
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
|
||
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
|
||
// rooted" check while being protocol-relative.
|
||
//
|
||
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
|
||
// wire frame into a call; this file is only the contract. Keeping them apart is
|
||
// what lets the declarations be read as a catalogue and diffed against §8.6.
|
||
|
||
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
|
||
// changes, not when one is added — an added optional is what `required: false`
|
||
// is for, and a stored rule keeps working across it.
|
||
const V1 = 1
|
||
|
||
|
||
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
|
||
//
|
||
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
|
||
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
|
||
// court. A letter is a sentence, and a template has no conditionals by design
|
||
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
|
||
// leaves a hole mid-clause: "The house , in , stands in peril."
|
||
//
|
||
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
|
||
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
|
||
// declared optional. Two shapes, and each `example` shows which it is —
|
||
//
|
||
// • a LABEL always has a value, so it can carry a sentence's spine;
|
||
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
|
||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||
//
|
||
// They are `required: false` and therefore additive: adding one is not a
|
||
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
|
||
// template written before them keeps working unchanged.
|
||
|
||
// ── Owned asset at risk — the flagship family ──────────────────────────────
|
||
//
|
||
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
|
||
// a website user, which is what `ownerUserId` on the envelope carries. A house
|
||
// or vendor whose owner never linked an account is nobody to notify, and the
|
||
// mapper drops it rather than treating it as an error.
|
||
|
||
const OWNED_ASSET = [
|
||
{
|
||
id: 'uo.house.idoc_warning',
|
||
label: 'Your house is decaying',
|
||
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
|
||
kind: 'event',
|
||
// The house, not the owner. A player with three decaying houses should hear
|
||
// about all three; a cooldown keyed on them would report one and swallow the
|
||
// rest. This is the case that makes `subjectKey` worth having at all.
|
||
subjectKey: 'houseSerial',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||
description: 'The house sign\'s name, when it has one.' },
|
||
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
|
||
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
|
||
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
|
||
description: 'The stage it was in before.' },
|
||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||
description: 'The named region the house stands in.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||
description: 'Facet and coordinates, already formatted for reading.' },
|
||
// **Protocol 5, and both are `required: false` on purpose.** A shard still
|
||
// running a v4 overlay emits no `schedule` at all, and a dynamic-decay shard
|
||
// omits `estimatedCollapse` at every stage before IDOC because ServUO draws
|
||
// each stage's duration at random when the stage is entered. So the mail has
|
||
// to read correctly without them — which is exactly what an optional
|
||
// variable and a template that omits an absent one give you.
|
||
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
|
||
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
|
||
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
|
||
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
|
||
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
|
||
description: 'When the house was last refreshed.' },
|
||
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||
description: 'Site-relative path to the IDOC page.' },
|
||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
|
||
description: 'The decay stage as words rather than as the wire\'s enum.' },
|
||
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
|
||
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.house.collapsed',
|
||
label: 'Your house collapsed',
|
||
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
|
||
kind: 'event',
|
||
subjectKey: 'houseSerial',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||
description: 'The house sign\'s name, when it had one.' },
|
||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||
description: 'The named region it stood in.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||
description: 'Facet and coordinates, already formatted for reading.' },
|
||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
|
||
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
|
||
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
|
||
],
|
||
},
|
||
{
|
||
// **The good outcome, and it exists because a delay without a cancel is just
|
||
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
|
||
// `delay_seconds: 900` so an owner who repairs the house inside the window is
|
||
// never told it is in peril — and until Phase 11b's live walk there was
|
||
// nothing that could cancel it: the mapper returned early on every transition
|
||
// that was not a late stage, so a refresh reached the engine as silence. The
|
||
// wire already carried the transition; only this declaration was missing.
|
||
//
|
||
// It is a real notification as well as a cancel signal (decision 11), so it
|
||
// carries the labels a body needs rather than the serial alone.
|
||
id: 'uo.house.refreshed',
|
||
label: 'Your house was refreshed',
|
||
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
|
||
kind: 'event',
|
||
// The SAME subject as the warning it cancels, and that is load-bearing rather
|
||
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
|
||
// whose subject were anything else would cancel nothing.
|
||
subjectKey: 'houseSerial',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||
description: 'The house, as the shard names it. Also the cooldown subject, and what the cancellation matches on.' },
|
||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||
description: 'The house sign\'s name, when it has one.' },
|
||
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
|
||
description: 'The decay stage it was in before it was refreshed.' },
|
||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||
description: 'The named region the house stands in.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||
description: 'Facet and coordinates, already formatted for reading.' },
|
||
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||
description: 'Site-relative path to the housing page.' },
|
||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||
{ name: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
|
||
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.vendor.expiring',
|
||
label: 'Your vendor is about to be dismissed',
|
||
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
|
||
kind: 'event',
|
||
subjectKey: 'vendorSerial',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
|
||
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
|
||
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
|
||
description: 'The shop\'s name.' },
|
||
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
|
||
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
|
||
// **The int an operator narrows with**, because `conditions.js` compares a
|
||
// declared variable against a LITERAL and has no relative-time operator:
|
||
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
|
||
// 24h`. So the hours are computed at emit and the operator writes
|
||
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
|
||
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
|
||
// on any price change.
|
||
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
|
||
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
|
||
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
|
||
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
|
||
{ name: 'funds', type: 'int', required: false, example: 8204,
|
||
description: 'Gold available to pay the fees.' },
|
||
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
|
||
description: 'What each tick deducts.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
|
||
description: 'Where the shop stands, already formatted for reading.' },
|
||
{ name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
|
||
description: 'Site-relative path to the market page.' },
|
||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
|
||
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Passive income ─────────────────────────────────────────────────────────
|
||
|
||
const PASSIVE_INCOME = [
|
||
{
|
||
id: 'uo.vendor.sale',
|
||
label: 'Your vendor sold something',
|
||
// **The tier caveat belongs in the operator-facing text, not only in a
|
||
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
|
||
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
|
||
// only against ServUO 57.4. A shard that declined the tier emits this kind
|
||
// never, so a rule on it is silently dormant rather than broken, and the only
|
||
// way an operator finds out is if something says so where they are looking.
|
||
description:
|
||
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
|
||
+ 'declined it never emits this event, and a rule on it stays silent.',
|
||
kind: 'event',
|
||
subjectKey: 'vendorSerial',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
|
||
description: 'The vendor that made the sale. Also the cooldown subject.' },
|
||
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
|
||
description: 'What was sold.' },
|
||
{ name: 'amount', type: 'int', required: false, example: 1,
|
||
description: 'How many.' },
|
||
{ name: 'price', type: 'int', required: true, example: 100,
|
||
description: 'What it sold for, in gold.' },
|
||
{ name: 'commission', type: 'int', required: false, example: 0,
|
||
description: 'Commission taken, on a commission vendor.' },
|
||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
|
||
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
|
||
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
|
||
description: 'The whole ledger line, or absent when the sale carried no commission.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Personal security ──────────────────────────────────────────────────────
|
||
|
||
const PERSONAL_SECURITY = [
|
||
{
|
||
id: 'uo.account.login_failed',
|
||
label: 'A failed login to your game account',
|
||
description: 'Someone tried to log into your game account and was refused.',
|
||
kind: 'event',
|
||
// The account, so a burst of attempts against one account is one mail and
|
||
// attempts against two accounts are two.
|
||
subjectKey: 'account',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||
description: 'The game account that was tried. Also the cooldown subject.' },
|
||
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
|
||
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
|
||
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||
description: 'Where the attempt came from.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.account.unlinked',
|
||
label: 'Your game account was unlinked',
|
||
description: 'Someone severed the tie between this game account and your website account, from in game.',
|
||
kind: 'event',
|
||
subjectKey: 'account',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||
description: 'The game account that was unlinked. Also the cooldown subject.' },
|
||
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
|
||
description: 'The character who ran the command.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Personal milestone ─────────────────────────────────────────────────────
|
||
//
|
||
// The two death triggers are a killfeed some players want and most do not.
|
||
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
|
||
// additionally default their channels `off` rather than relying on the rule
|
||
// switch alone.
|
||
|
||
const PERSONAL_MILESTONE = [
|
||
{
|
||
id: 'uo.skill.capped',
|
||
label: 'You capped a skill',
|
||
description: 'One of your characters reached the cap in a skill.',
|
||
kind: 'event',
|
||
subjectKey: 'skill',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||
description: 'The character who capped it.' },
|
||
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
|
||
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
|
||
{ name: 'cap', type: 'float', required: true, example: 100,
|
||
description: 'The cap that was reached.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.quest.complete',
|
||
label: 'You completed a quest',
|
||
description: 'One of your characters finished a quest.',
|
||
kind: 'event',
|
||
subjectKey: 'quest',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||
description: 'The character who finished it.' },
|
||
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
|
||
description: 'The quest. Also the cooldown subject.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.character.death',
|
||
label: 'Your character died',
|
||
description: 'One of your characters was killed. Opt-in — most players do not want this.',
|
||
kind: 'event',
|
||
subjectKey: 'characterName',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||
description: 'Who died. Also the cooldown subject.' },
|
||
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
||
description: 'What killed them, when the shard names it.' },
|
||
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
|
||
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.character.murdered',
|
||
label: 'Your character was murdered',
|
||
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
|
||
kind: 'event',
|
||
subjectKey: 'characterName',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||
description: 'Who was murdered. Also the cooldown subject.' },
|
||
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
||
description: 'Who did it, when the shard names them.' },
|
||
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
|
||
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Social / civic ─────────────────────────────────────────────────────────
|
||
//
|
||
// The two guild triggers ceiling at `members` and resolve through the recipient
|
||
// set the emit carries, not through a saved segment: "the members of THIS guild"
|
||
// is a different answer for every firing, which a segment's constant params
|
||
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
|
||
// precedent it was built for.
|
||
|
||
const SOCIAL_CIVIC = [
|
||
{
|
||
id: 'uo.guild.left',
|
||
label: 'A member left your guild',
|
||
description: 'Someone left a guild you are in.',
|
||
kind: 'event',
|
||
subjectKey: 'guildName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||
description: 'The guild. Also the cooldown subject.' },
|
||
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
|
||
// mobile has already left, so the shard has nothing to attribute. The name
|
||
// comes from this module's own roster mirror (`shard_guild_members`), and
|
||
// is optional because a member the sweep never saw has no row there.
|
||
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
|
||
description: 'Who left, when the roster mirror still knows their name.' },
|
||
{ name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
|
||
description: 'Site-relative path to the guilds page.' },
|
||
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
|
||
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.guild.disbanded',
|
||
label: 'Your guild disbanded',
|
||
description: 'A guild you are in was disbanded or removed.',
|
||
kind: 'event',
|
||
subjectKey: 'guildName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||
description: 'The guild that is gone. Also the cooldown subject.' },
|
||
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
|
||
description: 'Its abbreviation.' },
|
||
],
|
||
},
|
||
{
|
||
// **The town's bulletin and the governor's letter are two triggers, not one**
|
||
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
|
||
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
|
||
// serial and links are keyed by account — and the same reasoning was silently
|
||
// assumed to cover this one. It does not: `city.update`'s `governor` field is
|
||
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
|
||
// `webId`. The new governor is addressable today, with no protocol change.
|
||
//
|
||
// Widening `uo.governor.elected` to two audiences was the tempting answer and
|
||
// was refused: one trigger means one rule means ONE template, and the town's
|
||
// announcement and the governor's letter are not the same text. Two also lets
|
||
// an operator run the announcement and leave the letter off, or the reverse.
|
||
id: 'uo.governor.appointed',
|
||
label: 'You were named governor',
|
||
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
|
||
kind: 'event',
|
||
// The city, not the governor: a player who somehow takes two seats in an hour
|
||
// should get two letters, and the seat is what the event is about.
|
||
subjectKey: 'city',
|
||
audience: 'owner',
|
||
ceiling: 'owner',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||
description: 'The city whose seat you now hold. Also the cooldown subject.' },
|
||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||
description: 'Your character\'s name, as the city knows it.' },
|
||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||
description: 'Who held the seat before, when there was someone.' },
|
||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||
description: 'Site-relative path to the governors page.' },
|
||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.governor.elected',
|
||
label: 'A town elected a governor',
|
||
description: 'A city has a new governor.',
|
||
kind: 'event',
|
||
subjectKey: 'city',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||
description: 'The city. Also the cooldown subject.' },
|
||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||
description: 'The new governor.' },
|
||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||
description: 'Who held the seat before, when there was someone.' },
|
||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||
description: 'Site-relative path to the governors page.' },
|
||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.election.opened',
|
||
label: 'Voting opened in a town',
|
||
// **The first trigger whose call to action genuinely expires**, which is why
|
||
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
|
||
// no deadline is a mail nobody acts on, and one delivered after the deadline
|
||
// is worse than none. 11b's template says the date, and the seeded rule uses
|
||
// no delay for the same reason.
|
||
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
|
||
kind: 'event',
|
||
subjectKey: 'city',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||
description: 'The city. Also the cooldown subject.' },
|
||
{ name: 'phase', type: 'string', required: true, example: 'vote',
|
||
description: 'Which phase opened: nominate or vote.' },
|
||
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
|
||
description: 'When the game decides for itself — the real deadline.' },
|
||
// The same instant a person can read. A `datetime` renders as the string the
|
||
// payload holds and core has no interpolation filters by design, so a body
|
||
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
|
||
// The machine value STAYS — an operator writes `is at most` conditions
|
||
// against it — and the body uses this one.
|
||
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
|
||
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
|
||
{ name: 'candidates', type: 'int', required: false, example: 3,
|
||
description: 'How many candidates stand.' },
|
||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||
description: 'Site-relative path to the governors page.' },
|
||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
||
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
||
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
|
||
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Come online now ────────────────────────────────────────────────────────
|
||
|
||
const COME_ONLINE = [
|
||
{
|
||
id: 'uo.champ.started',
|
||
label: 'A champion spawn started',
|
||
description: 'A champion spawn became active.',
|
||
kind: 'event',
|
||
subjectKey: 'spawnSerial',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||
description: 'The spawn controller. Also the cooldown subject.' },
|
||
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||
description: 'What is spawning.' },
|
||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||
description: 'champion, mini or sea.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||
description: 'Where, already formatted for reading.' },
|
||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||
description: 'Site-relative path to the champions page.' },
|
||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.champ.boss_up',
|
||
label: 'A champion boss is up',
|
||
description: 'A champion spawn reached its boss.',
|
||
kind: 'event',
|
||
subjectKey: 'spawnSerial',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||
description: 'The spawn controller. Also the cooldown subject.' },
|
||
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||
description: 'The spawn.' },
|
||
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
|
||
description: 'The boss, when the shard names it.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||
description: 'Where, already formatted for reading.' },
|
||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||
description: 'Site-relative path to the champions page.' },
|
||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||
],
|
||
},
|
||
{
|
||
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
|
||
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
|
||
// where: [...], count: 1 }` is how an author says "move to the next phase when
|
||
// the boss falls", and a condition is expressed over a trigger firing. That is
|
||
// also why it is declared here rather than only ingested — a kind nothing
|
||
// declares is a kind no event can wait on.
|
||
id: 'uo.champ.boss_killed',
|
||
label: 'A champion boss was defeated',
|
||
description: 'Players brought down a champion spawn boss.',
|
||
kind: 'event',
|
||
subjectKey: 'spawnSerial',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
|
||
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
|
||
description: 'The boss that fell.' },
|
||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||
description: 'champion or sea.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
|
||
description: 'Where, already formatted for reading.' },
|
||
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
|
||
description: 'Who struck the last blow, when the shard names one.' },
|
||
{ name: 'damagerCount', type: 'int', required: false, example: 14,
|
||
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
|
||
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
|
||
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
|
||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||
description: 'Site-relative path to the champions page.' },
|
||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.server.up',
|
||
label: 'The shard came online',
|
||
description: 'The game server started or came back after an outage.',
|
||
kind: 'event',
|
||
// **No `subjectKey`, and that is the whole point of this pair.** There is one
|
||
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
|
||
// the shard bounced more than once an hour". Keying it on a boot id would make
|
||
// every restart a new subject and every cooldown a no-op, which is precisely
|
||
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
|
||
// a hard cooldown; this declaration is what makes that cooldown mean anything.
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||
description: 'What the shard calls itself.' },
|
||
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||
description: 'Site-relative path to the shard status page.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.server.down',
|
||
label: 'The shard went offline',
|
||
description: 'The game server shut down or crashed.',
|
||
kind: 'event',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||
description: 'What the shard calls itself.' },
|
||
{ name: 'clean', type: 'boolean', required: false, example: true,
|
||
description: 'Whether it was a clean shutdown rather than a crash.' },
|
||
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||
description: 'Site-relative path to the shard status page.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Leaderboard ────────────────────────────────────────────────────────────
|
||
|
||
const LEADERBOARD = [
|
||
{
|
||
id: 'uo.points.rank_changed',
|
||
label: 'A leaderboard top spot changed',
|
||
// §8.6 originally described this firing both ways — "you entered a top N" and
|
||
// "you were pushed out". The personal half is carved out: `points.board`'s
|
||
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
|
||
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
|
||
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
|
||
// leaderboard mail that reaches half the board reads as favouritism, so the
|
||
// board feed ships and the personal one waits for a serial→account map.
|
||
description: 'The top of a leaderboard changed hands.',
|
||
kind: 'event',
|
||
subjectKey: 'system',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
|
||
description: 'The points system. Also the cooldown subject.' },
|
||
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
|
||
description: 'Its display name, when the shard gives one.' },
|
||
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
|
||
description: 'Who is first now.' },
|
||
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
|
||
description: 'Who was first before.' },
|
||
{ name: 'points', type: 'int', required: false, example: 29500,
|
||
description: 'The new leader\'s points.' },
|
||
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
|
||
description: 'A label: the board\'s display name, or its system id when it has none.' },
|
||
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
|
||
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Staff-facing ───────────────────────────────────────────────────────────
|
||
//
|
||
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
|
||
// trigger out of a player's preferences catalogue AND gates it on write, so this
|
||
// family is the production proof of that work rather than new mechanism.
|
||
|
||
const STAFF_FACING = [
|
||
{
|
||
id: 'uo.page.new',
|
||
label: 'A player opened a help page',
|
||
description: 'A player raised a support ticket in game.',
|
||
kind: 'event',
|
||
subjectKey: 'pageType',
|
||
audience: 'staff',
|
||
ceiling: 'staff',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
|
||
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
|
||
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
|
||
description: 'Who raised it.' },
|
||
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
|
||
description: 'What they wrote.' },
|
||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
|
||
description: 'Where they are, already formatted for reading.' },
|
||
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
|
||
description: 'Site-relative path to the help-page queue.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.cheat.detected',
|
||
label: 'The cheat detector fired',
|
||
description: 'The shard\'s own speed-hack detector flagged a player.',
|
||
kind: 'event',
|
||
// **`staff`, and never `owner`.** This is the declaration the whole lattice
|
||
// was written for: under a flat "fewer people is narrower" ordering a
|
||
// `staff` ceiling would also permit `owner`, and the rule an operator would
|
||
// then be able to save mails the cheat report to the player who was detected.
|
||
subjectKey: 'characterName',
|
||
audience: 'staff',
|
||
ceiling: 'staff',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||
description: 'Who was flagged. Also the cooldown subject.' },
|
||
{ name: 'account', type: 'string', required: false, example: 'seed_000',
|
||
description: 'Their game account.' },
|
||
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||
description: 'Where they were connected from.' },
|
||
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
|
||
description: 'Which detector fired.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// ── Operator-facing ────────────────────────────────────────────────────────
|
||
//
|
||
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
|
||
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
|
||
// digest of what moderators did at `staff` would have sent it to the moderators.
|
||
// All three are digest-shaped by nature; none should ever be instant, which is a
|
||
// property of 11b's seeded rules rather than of these declarations.
|
||
|
||
const OPERATOR_FACING = [
|
||
{
|
||
id: 'uo.audit.staff_action',
|
||
label: 'A staff member acted in game',
|
||
description: 'A staff command, a property change, or a moderation action.',
|
||
kind: 'event',
|
||
subjectKey: 'staffName',
|
||
audience: 'admin',
|
||
ceiling: 'admin',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
|
||
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
|
||
{ name: 'action', type: 'string', required: true, example: 'set',
|
||
description: 'What kind of action: set, command, ban, kick, mute…' },
|
||
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
|
||
description: 'The action in one line, already formatted for reading.' },
|
||
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
|
||
description: 'Who or what it was applied to.' },
|
||
{ name: 'origin', type: 'string', required: false, example: 'in-game',
|
||
description: 'web or in-game — where the action was issued from.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.economy.milestone',
|
||
label: 'The economy crossed a threshold',
|
||
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
|
||
kind: 'event',
|
||
subjectKey: 'metric',
|
||
audience: 'admin',
|
||
ceiling: 'admin',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'metric', type: 'string', required: true, example: 'gold',
|
||
description: 'gold or accounts. Also the cooldown subject.' },
|
||
{ name: 'value', type: 'int', required: true, example: 1000000000,
|
||
description: 'The value that crossed.' },
|
||
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
|
||
description: 'The threshold it crossed.' },
|
||
{ name: 'direction', type: 'string', required: true, example: 'up',
|
||
description: 'up or down.' },
|
||
{ name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
|
||
description: 'Site-relative path to the shard status page.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'uo.world.saved',
|
||
label: 'The world saved',
|
||
description: 'A world save completed, with the item and mobile counts it wrote.',
|
||
kind: 'event',
|
||
audience: 'admin',
|
||
ceiling: 'admin',
|
||
version: V1,
|
||
variables: [
|
||
{ name: 'items', type: 'int', required: false, example: 1482301,
|
||
description: 'Items written.' },
|
||
{ name: 'mobiles', type: 'int', required: false, example: 41022,
|
||
description: 'Mobiles written.' },
|
||
],
|
||
},
|
||
]
|
||
|
||
const TRIGGERS = [
|
||
...OWNED_ASSET,
|
||
...PASSIVE_INCOME,
|
||
...PERSONAL_SECURITY,
|
||
...PERSONAL_MILESTONE,
|
||
...SOCIAL_CIVIC,
|
||
...COME_ONLINE,
|
||
...LEADERBOARD,
|
||
...STAFF_FACING,
|
||
...OPERATOR_FACING,
|
||
]
|
||
|
||
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
|
||
// emit an id this file does not declare, so a typo there is a boot-time-visible
|
||
// mistake rather than a dropped event nobody notices.
|
||
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
|
||
|
||
module.exports = {
|
||
TRIGGERS,
|
||
TRIGGER_IDS,
|
||
OWNED_ASSET,
|
||
PASSIVE_INCOME,
|
||
PERSONAL_SECURITY,
|
||
PERSONAL_MILESTONE,
|
||
SOCIAL_CIVIC,
|
||
COME_ONLINE,
|
||
LEADERBOARD,
|
||
STAFF_FACING,
|
||
OPERATOR_FACING,
|
||
}
|