Files
Module-uo/server/config/shardAudiences.js
wtclaude 419dee3e49
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
feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
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>
2026-08-31 20:28:17 -05:00

100 lines
4.8 KiB
JavaScript

// ── module-uo's registered audiences ───────────────────────────────────────
//
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
// linked a game account".
//
// **This is a different mechanism from the `members` audience the guild triggers
// use, and the difference is worth stating because the words are the same.** A
// guild event is about the members of THAT guild, which is a different answer for
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
// and the access-checked set travels on the envelope as `recipientUserIds`
// instead (Phase 6, decision 2). What is here answers the same question every
// time it is asked, which is exactly what makes it composable and storable.
//
// **Four rules, all of them from §5.1a:**
//
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
// list and a `resolve` it may call. It has never heard of a guild.
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
// template, a channel or an address and cannot enumerate them. A module still
// cannot send mail, and this must not become the door that lets it — core
// maps ids to addresses on its own side, after preferences, suppression and
// the verification gate.
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
// contains, and the result is still checked against the trigger's own.
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
// than erroring or silently reaching a different set of people.
//
// All three ceiling at `members`, and none higher. `members` is the lattice value
// for "a module-declared list", and it is the honest one here: these sets are not
// "everyone signed in" narrowed down, they are lists this module happens to know.
//
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
// calls, and every one of them fails to the EMPTY set rather than throwing — a
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
// behaviour and much better than a rule that 500s the engine.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const core = require('../core')
const log = core.logger('shard-audiences')
// One wrapper, so every resolver has the same failure behaviour and none of them
// has to remember it. A resolver that throws would fail the whole enqueue for
// every other audience in the same segment.
const safely = (id, fn) => async (params) => {
try {
return await fn(params || {})
} catch (err) {
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
return []
}
}
const AUDIENCES = [
{
// `namespaced()` requires the module's own prefix, so these are declared with
// it rather than relying on core to add one. Audiences have their own id
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
// `uo.guild.members` here does not collide with any trigger id.
id: 'uo.guild.members',
label: 'Members of a guild',
description: 'Everyone with a linked game account on one guild\'s roster.',
params: [{ id: 'guildId', type: 'int', required: true }],
ceiling: 'members',
resolve: safely('uo.guild.members', async ({ guildId }) => {
if (guildId == null) return []
const accounts = await shardState.listGuildMemberAccounts(guildId)
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.governors',
label: 'Town governors',
description: 'Everyone with a linked game account currently holding a city governorship.',
params: [],
ceiling: 'members',
resolve: safely('uo.governors', async () => {
const accounts = await shardState.listGovernorAccounts()
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.linked.accounts',
label: 'Players with a linked game account',
// The set an operator reaches for first, and — more usefully — the one a
// `not` composes against: "everyone who has NOT linked" is the audience for
// the message that asks them to.
description: 'Every website user who has linked at least one game account.',
params: [],
ceiling: 'members',
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
},
]
module.exports = { AUDIENCES }