feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edge → main)
#26
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
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?
Engagement Phase 13, step 4 of 7. The UO module, after the core it declares against.
Merge step 3 (
website) first —module.jsondeclarescoreApi ^1.9.0andMODULE_API_VERSIONreachesmainonly in that PR. The check is at load, not at install, so a module installed onto an older core does not refuse the install; it accepts it and then fails to load.One commit still to come on this branch
ci/core-ref.jsoncurrently pins anedgesha (52eac24) — it had to, becausefrozen-manifestclones core at that pin and 1.9.0 existed nowhere else. It reverts to amainsha once step 3 lands, as its own small PR intoedge, which this PR will then carry. Do not merge this before that lands or the pin ships naming a branch that is about to stop existing as the authority.What lands (5 merges, 27 files)
schedule,vendor.listing'sfeesandownerAcct, andaccount.login.result. Includes the schema's protocol pin:uo_link_config.protocoldefaults to 5 and migrates a< 5install once, guarded by a settings keyuo.market.item_listed(it is a saved search, and no per-user query store exists)enabled = 0uo.house.refreshed. The market sweep never diffed the v5feesblock, so a vendor running out of gold was invisibleThe pin this changes for operators
The protocol migration lives here, not in core, so it is the module update that moves a deployment to protocol 5 and starts the site sending
X-UOLink-Version: 5. A shard still on 4 answers409from that boot on. That is why the operator note orders the upgrade website → module → shard, and says what to do if the shard host cannot be reached in the same window.frozen-manifestRed on every engagement-phase PR in this repo from 11a onward, because the pin named a 1.6.0 core with no
ceilings.jsand noregister*calls. Fixed in #25 and green since (run 43).The website half of the protocol-5 bump. Engagement Phase 10. Schema — twelve columns and two indexes. shard_houses gains next_stage, estimated_collapse, decay_period_sec and dynamic_decay. estimated_collapse is nullable and stays null far more often than not, deliberately: under dynamic decay ServUO draws each stage at random on entry, so collapse is knowable only at IDOC. A null means "not knowable", never "not yet read". shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at. owner_acct is the structural one — the table has carried owner_name since protocol 3, but a character name joins to nothing, and only the game account reaches shard_account_links. Until now a vendor row named an owner the site could not resolve to a person. dismissal_at + owner_acct are what let Phase 11's uo.vendor.expiring find "vendors about to be dismissed" and turn each into a person, without scanning every shop. Ingest. Both new field groups arrive NESTED and are flattened into columns on the way in, then re-nested on the way out — the same trick shardMarket already uses for `location`. That is not stylistic: the visibility projection matches literal JSON keys, so the stored read model and the live wire frame have to spell a group identically or one admin rule covers only one of the two paths. It also means a field added inside a group later inherits the group's gate instead of defaulting to visible; there is a test that adds an imaginary future fee field and asserts exactly that. Two write-back asymmetries, both load-bearing: * ownerName is written ONLY when the frame carries one. house.update also writes that column, from a different sweep, and a pre-v5 overlay's house.decay carries no ownerName at all — coalescing to null would let every decay transition erase a name the registry had already resolved. * The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A schedule is a claim about the future and goes stale on its own: roll a shard back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value is nothing. A dismissal date nobody is maintaining is worse than none. dismissalAt is taken from the shard rather than recomputed. The shard resolved it against ServUO's two vendor systems, whose charge, funds and pay interval all differ; re-deriving it here would be a second implementation of PlayerVendor's own rule. Visibility — three classifications, each chosen rather than inherited. * house.decay's `schedule` defaults to `anonymous`. The countdown IS the public IDOC page's content and a house at IDOC is already announced in game. Listed anyway so a shard that considers a precise collapse time an unfair advantage can raise it — and one nested rule takes the whole schedule with it. * vendor.listing's `fees` defaults to `admin`, the only default in the market feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce. Shop name, owner and location are already visible to any player through the in-game Vendor Search gump, which is the argument for publishing them. Held gold, daily charge and dismissal date are visible to the OWNER only, on that vendor's own gump. Publishing them anonymously would be a new disclosure and a targeting aid — which shops are about to be abandoned, and how much coin is in each. * account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds an admin may widen, and there is no rung below admin that an IP plus an auth verdict belongs on. The omission is the decision, and a test says so by name. owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no REST read model's column list — they exist for Phase 11's server-side trigger and reach no client at all. The pin, and the protocol-4 bug seen from the other side. Both declaration sites go to 5 (the model constant and schema.sql's CREATE default), plus the one-shot migration, guarded `protocol < 5` so an install that missed an earlier step is carried the whole way. The schema test used to assert `DEFAULT 4` at each site. That is exactly how protocol 4 shipped with the emitters moved and one site left behind: every site agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the model, so the assertion is "the declarations AGREE", and the one-shot migration test is written once against the current version instead of being hand-copied per bump. 470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO and the release sidecar. Docs: RunicGateway/docs link/v5.md. Co-Authored-By: Claude <noreply@anthropic.com>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>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>