feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Failing after 41s

module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.

server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.

server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:

  * Transitions. champ.update and city.update are full-state upserts, so without
    a per-process tracker a sidecar reconnect reads as twenty spawns starting.
    A FIRST sighting is never a transition.
  * Thresholds. conditions.js compares a declared variable against a LITERAL, so
    "within 24 hours of dismissal" is not expressible; and vendor.listing is a
    sweep frame re-emitted on any price change, so per-frame would flood. The
    crossing is tracked here and `hoursRemaining` is declared so an operator can
    still narrow with `is at most`.
  * The members audience. "The members of THIS guild" differs every firing, so
    it travels on the envelope as recipientUserIds (Phase 6 decision 2).

**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.

Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).

coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.

39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 20:28:17 -05:00
parent 75f9b27687
commit 419dee3e49
14 changed files with 2280 additions and 4 deletions

View File

@@ -39,4 +39,56 @@ const remove = (account, userId) =>
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
// A bound on every "resolve a set of people" read below. It mirrors core's own
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
// cannot reach into core's models, and the number this file has to respect is
// "no more ids than core will accept" whatever core calls it.
const MAX_AUDIENCE = 5000
// **Website user ids for a set of game accounts.** The bulk form of
// `getByAccount`, and the one the engagement mapper needs: a guild event's
// audience is its members, and turning a roster into a set of people is one join
// rather than one query per member (Phase 11).
//
// DISTINCT because two characters on one guild roster can share an account, and
// the caller wants people rather than characters.
async function userIdsForAccounts(accounts) {
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
if (!wanted.length) return []
const capped = wanted.slice(0, MAX_AUDIENCE)
const marks = capped.map(() => '?').join(', ')
const rows = await query(
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
capped,
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
// **Every website user with a linked game account** — the `uo.linked.accounts`
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
// one a `not` composes against ("everyone who has NOT linked").
//
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
// never sees an address, a channel or a template, and core maps ids to addresses
// on its own side after preferences, suppression and the verification gate.
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
const rows = await query(
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
[limit],
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
module.exports = {
upsert,
getByAccount,
listByUser,
isOwnedBy,
remove,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
MAX_AUDIENCE,
}

View File

@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
// The bulk resolvers the engagement audiences and the guild mapper need
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
// put here, and a module's audience resolver returning ids and nothing else is
// the contract (§5.1a rule 2).
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
module.exports = {
link,
listForUser,
ownsAccount,
getByAccount,
unlink,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
}

View File

@@ -220,6 +220,26 @@ const listGuildMembers = (guildId) =>
guildId,
])
// **The game accounts on one guild's roster** — the input to
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
// the authoritative map. A mirror that has drifted would mail the wrong person,
// and a mirror that is behind would mail nobody, so the query that decides who
// is told reads the table whose job that is.
const listGuildMemberAccounts = (guildId) =>
query(
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
[guildId],
)
// The accounts of every sitting governor — the `uo.governors` audience.
// `governor_acct` is NULL on a city with no governor and on one whose governor's
// mobile has no account, and both are simply nobody.
const listGovernorAccounts = () =>
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -396,6 +416,8 @@ module.exports = {
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,

View File

@@ -462,6 +462,19 @@ async function listGuildMembers(guildId) {
}))
}
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
// through `shardVisibility`, which strips `acct` for anyone below admin, so
// building an audience out of it would either leak the projection's job into
// this one or silently resolve to nobody depending on who asked. These two go to
// the database for exactly the column they need and pass nothing else on.
const listGuildMemberAccounts = async (guildId) =>
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
const listGovernorAccounts = async () =>
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
@@ -742,6 +755,8 @@ module.exports = {
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,