feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a) #22
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-triggers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
module-uo's half of ENGAGEMENT.md Phase 11a. Pairs with website (theadminceiling,MODULE_API_VERSION→ 1.8.0, core'snews.postemitter) and docs#194 (the six decisions this was built to).Nothing here sends anybody anything. A trigger is a payload contract — what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever give it. No rule and no template is seeded; that is 11b.
What landed
server/config/shardTriggers.jsserver/config/shardAudiences.jsuo.guild.members,uo.governors,uo.linked.accountsserver/utils/shardEngagement.jsctx.events.emitmapper and its transition/threshold trackersserver/utils/shardIngest.jsserver/core.jsctx.events/ctx.inboxreach the modulemodule.jsoncoreApi→^1.8.0, version → 0.4.0Ceilings: 10
owner, 2members, 7authenticated, 2staff, 3admin(the value core adds in the same window).uo.cheat.detectedatstaffis the declaration the whole lattice exists for — under a flat "fewer people is narrower" ordering astaffceiling would also permitowner, and the rule an operator could then save mails the cheat report to the player who was detected.The ordering that is load-bearing
The engagement fan-out runs BEFORE
applyStateChange, and it is awaited. This is the one placeingestwaits on a notification path, and it has to:account.unlinkeddrops theshard_account_linksrow — the row that names the one person who needs to be told their account was unlinked. Resolving afterwards finds nobody, every time.house.removedrops the house, whose storedownerAcctis the only place the owner of a collapsed house appears (the frame carries a serial alone).guild.leave/guild.removeneed the roster and board mirrors to name who left and which guild it was.A fire-and-forget promise would race the DELETE it is trying to precede.
fromShardEventnever throws and opens no socket — it resolves ids and hands the engine an envelope, andemitis deliberately not awaited inside that.Three things a rule cannot express, so they live in the mapper
conditions.jscompares a declared variable against a literal — no arithmetic, no relative time, no previous value.champ.updateandcity.updateare full-state upserts re-emitted on any change, so without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A first sighting is never a transition — the same ruleshardStreams.jsalready holds for push.vendor.listingis a sweep frame re-emitted whenever anyone reprices an item, souo.vendor.expiringfires on the crossing into a 48-hour window; a deposit that leaves the window re-arms it. It declareshoursRemainingas an int so an operator can still narrow withis at most 24.uo.economy.milestonecrosses a gold/account line in either direction, never on first sight.membersaudience. "The members of this guild" is a different answer every firing, which a saved segment's constant params cannot express, so the access-checked set travels on the envelope asrecipientUserIds(Phase 6, decision 2 — the mechanism the Team fan-out was built on). Resolved throughshard_account_links, not through the roster's mirroredweb_id: the mirror is a copy of what the wire said, and the links table is the answer.Two protocol facts the mapper obeys
account.login.result, neveraccount.login.attempt. The attempt fires from a sink that runs before the auth decision and whose args defaultAccepted = true, so a rule on it would have mailed a security alert on every successful login. That inversion is why v5 added the kind and why the trigger isuo.account.login_failed. The attempt kind is deliberately left unmapped, with a test that says so.estimatedCollapsemeans "not knowable", never "not yet read". Under dynamic decay ServUO draws each stage's duration at random when the stage is entered, so the mapper passes the absence through rather than computing a fallback — which would republish exactly the guess the shard refused to make.Four rows that deliberately do not ship
Phase 11 inverted the burden of proof, so each carries a reason (recorded in docs#194 §8.6 and in
docs/modules/uo/API.md§5.2):uo.market.item_listeduo.guild.joinedteam.member.joinedalready fires for it — a UO guild is a Team and this module is the provider, so the roster reconcile emits on every join.uo.guild.leftanduo.guild.disbandedship: core has neitheruo.link.requesteduo.points.rank_changed's personal halftop[]names a mobile serial,shard_account_linksis keyed by accountA test asserts all three carved-out ids are absent, so adding one back has to edit a test that says why.
coreApi→^1.8.0Not cosmetic. The module now calls
api.registerEventTriggersand declaresceiling: 'admin': a 1.7.0 core refuses the ceiling and a 1.6.0 core does not have the method at all (which is exactly how the stale test fake failed before it was updated).^1.3.0would have let it load into a core that crashes it at registration.Verification
check:importsclean ·check:bundleclean (60 reachable files, all shipped) ·check:swaggerclean.registries.jsalongside the 7 grandfathered stream ids, to prove the one-namespace rule holds and every ceiling/audience pair is accepted.Note for the reviewer
swagger-fragment.jsonreports STALE whencheck:swaggerruns on Windows. It is a line-ending artifact — regenerating produces a byte-identical file modulo CRLF, andgit diffshows no content change — so nothing is committed for it. It is pre-existing onedgeand passes on CI's Linux checkout.AI disclosure
Co-Authored-By: Claudetrailer.🤖 Generated with Claude Code
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>