feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
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

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>
This commit is contained in:
2026-09-01 01:02:15 -05:00
parent 1a866112e4
commit 50a89b48e2
8 changed files with 1431 additions and 10 deletions

View File

@@ -56,6 +56,27 @@
// 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` →
@@ -103,6 +124,12 @@ const OWNED_ASSET = [
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.' },
],
},
{
@@ -123,6 +150,10 @@ const OWNED_ASSET = [
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.' },
],
},
{
@@ -160,6 +191,10 @@ const OWNED_ASSET = [
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.' },
],
},
]
@@ -195,6 +230,12 @@ const PASSIVE_INCOME = [
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.' },
],
},
]
@@ -296,6 +337,8 @@ const PERSONAL_MILESTONE = [
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.' },
],
},
{
@@ -312,6 +355,8 @@ const PERSONAL_MILESTONE = [
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.' },
],
},
]
@@ -345,6 +390,8 @@ const SOCIAL_CIVIC = [
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.' },
],
},
{
@@ -363,6 +410,42 @@ const SOCIAL_CIVIC = [
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',
@@ -381,6 +464,8 @@ const SOCIAL_CIVIC = [
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.' },
],
},
{
@@ -408,6 +493,10 @@ const SOCIAL_CIVIC = [
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.' },
],
},
]
@@ -435,6 +524,8 @@ const COME_ONLINE = [
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.' },
],
},
{
@@ -457,6 +548,8 @@ const COME_ONLINE = [
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.' },
],
},
{
@@ -529,6 +622,10 @@ const LEADERBOARD = [
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.' },
],
},
]