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>
This commit is contained in:
@@ -144,6 +144,66 @@ const actorAcct = (actor) => (actor && typeof actor === 'object' ? actor.acct :
|
||||
// honest about what the frame actually carried.
|
||||
const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
|
||||
|
||||
|
||||
// ── Presentational fragments (ENGAGEMENT.md Phase 11b, decision 8) ─────────
|
||||
//
|
||||
// **A template has no conditionals, by design** (`interpolate.js`: no filters,
|
||||
// no loops, no ternaries), and an unset optional interpolates to the EMPTY
|
||||
// STRING. That is exactly right for `notify.event`, whose variables are
|
||||
// structural — but the in-universe bodies are sentences, and a sentence with a
|
||||
// hole in the middle of it reads as a bug: "The house , in , stands in peril."
|
||||
//
|
||||
// So the ternary stays at the call site and its RESULT arrives as a declared
|
||||
// optional variable, which is Phase 5a's `forWhom` precedent unchanged. Two
|
||||
// shapes, and the difference matters when you write one:
|
||||
//
|
||||
// • a LABEL always has a value, so it can carry a sentence's spine
|
||||
// (`houseLabel` is a name, or a seal number when there is no name);
|
||||
// • a TRAILING FRAGMENT may be empty and leads with its own space, so the
|
||||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||||
//
|
||||
// Every one of them is declared `required: false` on the trigger with an
|
||||
// `example` showing precisely what it produces, leading space included — which
|
||||
// is what the template editor previews and test-sends with.
|
||||
|
||||
/** A trailing fragment, or undefined when there is nothing to say. */
|
||||
const trailing = (value, build) => (value ? build(value) : undefined)
|
||||
|
||||
// "The Silver Anvil, in Britain" · "the house under seal 0x40001234". A house
|
||||
// often has no name and sometimes no region, and the warning has to name
|
||||
// SOMETHING the owner can act on — a seal number is worse prose and better than
|
||||
// a blank.
|
||||
const houseLabel = (name, region, serial) => {
|
||||
const named = name ? `“${name}”` : `the house under seal ${serial}`
|
||||
return region ? `${named}, in ${region}` : named
|
||||
}
|
||||
|
||||
// The decay stages as words rather than as the wire's enum. `Greatly` in the
|
||||
// middle of a sentence is the shard's vocabulary leaking into a letter.
|
||||
const STAGE_WORDS = {
|
||||
FAIRLY: 'fairly worn',
|
||||
GREATLY: 'greatly worn',
|
||||
IDOC: 'in imminent danger of collapse',
|
||||
}
|
||||
const stageLabel = (stage) => STAGE_WORDS[String(stage || '').toUpperCase()] || 'in decay'
|
||||
|
||||
// The election phases likewise: `nominate` and `vote` are wire values.
|
||||
// A whole DETAIL LINE, assembled from the parts that are actually present.
|
||||
//
|
||||
// The same argument `place()` above makes, one level up: a template that has to
|
||||
// assemble four optional numbers into a sentence is a template every author gets
|
||||
// slightly differently, and one whose optionals are absent renders
|
||||
// "On hand: gold. Charged each period: gold." — which is what the render sweep
|
||||
// found on a pre-v5 vendor frame. Passing the assembled line means the template
|
||||
// interpolates ONE variable and the empty case is empty rather than punctuated.
|
||||
const detailLine = (parts) => {
|
||||
const kept = parts.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
return kept.length ? kept.map(([label, v]) => `${label}: ${v}`).join('. ') + '.' : undefined
|
||||
}
|
||||
|
||||
const PHASE_WORDS = { nominate: 'Nominations are open', vote: 'The ballot is open' }
|
||||
const phaseLabel = (phase) => PHASE_WORDS[String(phase || '')] || 'The election has moved'
|
||||
|
||||
// ── The mappers ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each returns an array of `{ triggerId, data, ownerAccount?, guildId?, subject?,
|
||||
@@ -173,6 +233,8 @@ const MAPPERS = {
|
||||
houseName: decayName(ev),
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
whereLine: detailLine([['Last recorded at', place(ev)]]),
|
||||
}),
|
||||
})
|
||||
return
|
||||
@@ -186,6 +248,9 @@ const MAPPERS = {
|
||||
houseSerial: serial,
|
||||
houseName: decayName(ev),
|
||||
stage: ev.to,
|
||||
houseLabel: houseLabel(decayName(ev), ev.region, serial),
|
||||
stageLabel: stageLabel(ev.to),
|
||||
whereLine: detailLine([['Recorded at', place(ev)], ['Stage entered', ev.to]]),
|
||||
previousStage: ev.from || undefined,
|
||||
region: ev.region || undefined,
|
||||
location: place(ev),
|
||||
@@ -244,6 +309,7 @@ const MAPPERS = {
|
||||
data: defined({
|
||||
vendorSerial: serial,
|
||||
shopName: ev.shopName || undefined,
|
||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||
dismissalAt: fees.dismissalAt,
|
||||
// Never negative: a vendor already past its dismissal tick is being
|
||||
// destroyed, and "-3 hours remaining" in a mail is worse than "0".
|
||||
@@ -252,6 +318,13 @@ const MAPPERS = {
|
||||
funds: Number.isFinite(fees.funds) ? fees.funds : undefined,
|
||||
chargePerPeriod: Number.isFinite(fees.chargePerPeriod) ? fees.chargePerPeriod : undefined,
|
||||
location: place(ev),
|
||||
ledgerLine: detailLine([
|
||||
['On hand', Number.isFinite(fees.funds) ? `${fees.funds} gold` : undefined],
|
||||
['Charged each period', Number.isFinite(fees.chargePerPeriod) ? `${fees.chargePerPeriod} gold` : undefined],
|
||||
['Periods remaining', Number.isFinite(fees.periodsRemaining) ? fees.periodsRemaining : undefined],
|
||||
['Dismissal', fees.dismissalAt],
|
||||
['Standing at', place(ev)],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
},
|
||||
@@ -269,9 +342,18 @@ const MAPPERS = {
|
||||
data: defined({
|
||||
vendorSerial: String(ev.vendorSerial ?? ''),
|
||||
itemName: ev.itemType || 'an item',
|
||||
// "3 × Iron Ingot" or just "Iron Ingot" — `amount` is optional and a
|
||||
// sentence reading "sold Iron Ingot" is the hole this closes.
|
||||
itemLine: Number.isFinite(ev.amount) && ev.amount > 1
|
||||
? `${ev.amount} × ${ev.itemType || 'an item'}`
|
||||
: (ev.itemType || 'an item'),
|
||||
shopLabel: ev.shopName ? `thy shop “${ev.shopName}”` : 'thy vendor',
|
||||
amount: Number.isFinite(ev.amount) ? ev.amount : undefined,
|
||||
price: Number.isFinite(ev.price) ? ev.price : 0,
|
||||
commission: Number.isFinite(ev.commission) ? ev.commission : undefined,
|
||||
ledgerLine: detailLine([
|
||||
['Commission withheld', Number.isFinite(ev.commission) ? `${ev.commission} gold` : undefined],
|
||||
]),
|
||||
}),
|
||||
})
|
||||
},
|
||||
@@ -355,6 +437,7 @@ const MAPPERS = {
|
||||
data: defined({
|
||||
characterName: actorName(ev.who) || 'your character',
|
||||
killerName: actorName(ev.killer),
|
||||
slainBy: trailing(actorName(ev.killer), (n) => ` at the hands of ${n}`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
@@ -368,6 +451,7 @@ const MAPPERS = {
|
||||
data: defined({
|
||||
characterName: actorName(ev.victim) || 'your character',
|
||||
murdererName: actorName(ev.murderer),
|
||||
slainBy: trailing(actorName(ev.murderer), (n) => ` by the hand of ${n}`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
@@ -412,14 +496,34 @@ const MAPPERS = {
|
||||
const prevGov = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
if (prevGov !== undefined && gov && gov !== prevGov) {
|
||||
out.push({
|
||||
triggerId: 'uo.governor.elected',
|
||||
data: defined({
|
||||
city: String(city),
|
||||
governorName: actorName(ev.governor) || 'a new governor',
|
||||
previousGovernorName: undefined,
|
||||
}),
|
||||
const governorName = actorName(ev.governor) || 'a new governor'
|
||||
// **The frame carries no previous holder by name.** The tracker holds the
|
||||
// outgoing governor's SERIAL and nothing maps a serial to a name here, so
|
||||
// the succession fragment is empty today and the declaration is optional.
|
||||
// It is declared rather than omitted so the body does not have to be
|
||||
// rewritten the day `city.update` gains a `previousGovernor` actor.
|
||||
const civic = defined({
|
||||
city: String(city),
|
||||
governorName,
|
||||
previousGovernorName: undefined,
|
||||
inSuccessionTo: undefined,
|
||||
})
|
||||
out.push({ triggerId: 'uo.governor.elected', data: civic })
|
||||
|
||||
// **And the letter to the person who won** (Phase 11b, decision 10). Same
|
||||
// frame, same transition, same never-on-first-sight guard — a different
|
||||
// audience and a different body. `BridgeJson.Actor()` writes `acct` on
|
||||
// every actor object it emits, so this needs no protocol change; an
|
||||
// unlinked governor is simply nobody to write to, which `resolveTarget`
|
||||
// already treats as an ordinary outcome rather than an error.
|
||||
const acct = actorAcct(ev.governor)
|
||||
if (acct) {
|
||||
out.push({
|
||||
triggerId: 'uo.governor.appointed',
|
||||
ownerAccount: acct,
|
||||
data: { ...civic },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An election opening. `autoPickAt` is REQUIRED on the trigger, so a phase
|
||||
@@ -440,8 +544,13 @@ const MAPPERS = {
|
||||
data: defined({
|
||||
city: String(city),
|
||||
phase,
|
||||
phaseLabel: phaseLabel(phase),
|
||||
autoPickAt: ev.autoPickAt,
|
||||
candidates: Number.isFinite(ev.candidates) ? ev.candidates : undefined,
|
||||
candidateNote: trailing(
|
||||
Number.isFinite(ev.candidates) && ev.candidates > 0 ? ev.candidates : null,
|
||||
(n) => (n === 1 ? ' One candidate stands.' : ` ${n} candidates stand.`),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -460,6 +569,7 @@ const MAPPERS = {
|
||||
spawnName: ev.name || ev.type || 'a champion spawn',
|
||||
category: ev.category || undefined,
|
||||
location: place(ev),
|
||||
atPlace: trailing(place(ev), (p) => ` at ${p}`),
|
||||
})
|
||||
|
||||
if (wasActive !== undefined && isActive && wasActive !== true) {
|
||||
@@ -531,7 +641,15 @@ const MAPPERS = {
|
||||
system: String(system),
|
||||
systemName: ev.nameString || undefined,
|
||||
leaderName: leader.name || 'a new leader',
|
||||
// The board frame carries no previous holder — the tracker holds only a
|
||||
// SERIAL, and a serial is not a name — so this is the one family whose
|
||||
// trailing fragment is always empty today. Declared anyway, because the
|
||||
// alternative is a body that has to be rewritten when the frame gains it.
|
||||
boardLabel: ev.nameString || String(system),
|
||||
points: Number.isFinite(leader.points) ? leader.points : undefined,
|
||||
standingLine: Number.isFinite(leader.points)
|
||||
? `${leader.name || 'a new leader'} now stands first upon it, with ${leader.points} to their name.`
|
||||
: `${leader.name || 'a new leader'} now stands first upon it.`,
|
||||
}),
|
||||
})
|
||||
},
|
||||
@@ -727,6 +845,11 @@ async function resolveTarget(target, deps) {
|
||||
const members = await state.listGuildMembers(target.guildId)
|
||||
const gone = members.find((m) => String(m.serial) === target.memberSerial)
|
||||
if (gone && gone.name) data.memberName = gone.name
|
||||
// The in-universe body's spine (Phase 11b decision 8). Built HERE and not
|
||||
// in the mapper because the name comes from the roster mirror, which the
|
||||
// mapper cannot read — and a herald's notice that names nobody is worse
|
||||
// than one that says "a member".
|
||||
if (data.guildName !== undefined) data.memberLabel = data.memberName || 'A member'
|
||||
}
|
||||
return { data, recipientUserIds: userIds }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user