diff --git a/link/v5.md b/link/v5.md index 8c3fafa..e048fab 100644 --- a/link/v5.md +++ b/link/v5.md @@ -122,6 +122,24 @@ Two naming decisions worth recording, because the obvious spellings are both wro a dynamic-decay house, though, there is **no randomness in it**: given the current funds it is the exact tick the vendor is destroyed on. +**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()` +compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made +of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A +vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the +consumer that exists for exactly that event could fire only by coincidence: when somebody happened to +reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and +fixed there (ENGAGEMENT.md, decision 13). + +The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An +integer division moves only when the shard's own answer to "is this vendor in danger" moves, while +`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a +fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape: +no field was added and `PROTOCOL_VERSION` is untouched. + +**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change +detector, and a field added to the frame that is not added to the detector is a field that ships +correct and arrives never. + ### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed `account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which diff --git a/modules/uo/API.md b/modules/uo/API.md index a74d48f..b67d98c 100644 --- a/modules/uo/API.md +++ b/modules/uo/API.md @@ -157,9 +157,9 @@ stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` retu ## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11) Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and -it is the one an operator interacts with by name. `module-uo` declares **25 event triggers** and +it is the one an operator interacts with by name. `module-uo` declares **26 event triggers** and **3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships -**32 message bodies and 25 rules** through `api.registerEngagementSeeds` +**34 message bodies and 26 rules** through `api.registerEngagementSeeds` ([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary — it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call. @@ -181,7 +181,7 @@ ceiling does not permit `owner`, because fewer people is not less exposure. | Ceiling | Triggers | Why that ceiling | | --- | --- | --- | -| `owner` | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All eleven resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify | +| `owner` | `uo.house.idoc_warning`, `uo.house.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All twelve resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify | | `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express | | `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want | | `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written for: under a flat "fewer people is narrower" ordering, a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected | @@ -265,7 +265,7 @@ Declaring a trigger says what an event IS. It says nothing about what the messag until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })` ([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its -first caller, with **32 bodies and 25 rules** in +first caller, with **34 bodies and 26 rules** in [`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js). **Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision @@ -302,13 +302,63 @@ not fixtures. Two mechanical notes that will bite whoever adds the twenty-sixth trigger: -- **All 25 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended +- **All 26 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must reach existing deployments takes a new group key. - **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is keyed `uo.champ.boss-up`. Registration refuses the mistake at boot. +### 5.7a `uo.house.refreshed` — the trigger that exists to cancel one + +Added by the live walk (ENGAGEMENT.md Phase 11b, decision 11), and it is the only trigger in this +module whose primary job is not to say something. + +`uo.house.idoc_warning`'s seeded rule carries `delay_seconds: 900` so that a player who repairs the +house inside the quarter-hour is never told it is in peril. That is only true if something CANCELS +the pending row, and until this trigger existed nothing could: `cancel_on` named `uo.house.collapsed` +— the outcome where the warning is pointless — and the mapper returned early on every transition that +was not a late decay stage, so a refresh reached the engine as silence. The wire had carried the +transition all along. + +``` +house.decay Greatly -> Ageless (the owner logged back in) + -> uo.house.refreshed (owner-audienced, subject = the house serial) + -> cancels every scheduled engagement_outbox row for + (the warning's rule, that house, that owner) + -> and, if the operator enabled its own rule, sends the Office of Deeds' + one non-warning letter +``` + +Three things about it are load-bearing: + +- **Its `subjectKey` is `houseSerial`, the same as the warning's.** `outboxDb.cancel` matches on + `(rule, subject_key)`, so a refresh carrying any other subject would cancel nothing at all. +- **It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case.** A condemned house + cannot be refreshed — `BaseHouse.RefreshDecay()` refuses `DecayType.Condemned` outright — so the + rescue is the owner logging in. Their newest house then becomes `AutoRefresh` and reads `Ageless`; + an older one becomes `ManualRefresh` and reads `LikeNew`. Reading only the second misses most + rescues. +- **The cancellation does not depend on its own rule.** `cancel_on` is read off the WARNING's rule, + so an operator who wants the cancellation without the reassurance letter simply leaves the new rule + disabled — which, every seeded rule shipping disabled, is what a fresh install already does. + +### 5.7b Every link a body offers comes from `config/clientPaths.js` + +A notification's call-to-action is a path into this module's own SPA routes, and there is exactly one +place that knows them. The live walk found every one of them wrong, in two independent ways: + +- the declared `example`s read `/shard/…`, taken from `module.json`'s `mounts` — but + `registry.registerRoutes` prefixes a module's client routes with its **ID** and nothing else, so + the real paths are `/uo/houses`, `/uo/market`, `/player/uo/characters`. Every example was a 404, + and an example is what the template editor previews and test-sends with; +- and no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href + and dropped out of the text part entirely. + +`clientPaths.js` is now the single source for both the declarations and the bodies. `client/src/entry.jsx`'s +own `registerNav` is the cross-check: the hrefs it hands the sidebar are these, and if the two ever +disagree the sidebar is right. + ### 5.8 The presentational fragments, and why they exist A template has **no conditionals**, by design, and an unset optional interpolates to the **empty diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 25b72bc..0c0baf9 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -545,21 +545,35 @@ The ceiling is a **stored column rather than a runtime computation** so an audit was allowed to reach without re-resolving it, and so a module that later widens its own audience's ceiling cannot retroactively widen a segment saved under the old one. -### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a) +### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b) | col | type | notes | |---|---|---| | rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | | | user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | | | subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject | +| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend | | last_fired_at | DATETIME NOT NULL | | | fire_count | INT NOT NULL DEFAULT 1 | | -`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune. +`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune. **`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on (rule, user) alone silently drops three of them. +**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that +it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so +without the channel in the key the first channel of a rule claimed the cooldown and every later one +was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and +in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per +occasion: an operator who says "one a day about this house" means one mail and one inbox item. + +Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a +key change** — replaying `schema.sql` on every boot would fail after the first run without the +`information_schema` guard that reads whether the key already carries the column. Rows written before +the migration keep `channel = ''` and expire on their own interval; dropping the table instead would +let a storm through the window. + **The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single `INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 4075624..4e6b036 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -3218,7 +3218,7 @@ this way, since the audience kind is the thing being tested: | Family | Triggers | Audience kind | Notes | | --- | --- | --- | --- | -| **Owned asset at risk** | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All three resolve through `ownerAcct` → `shard_links` | +| **Owned asset at risk** | `uo.house.idoc_warning`, `uo.house.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All four resolve through `ownerAcct` → `shard_links`. `uo.house.refreshed` was added by decision 11 during the live walk: it is what CANCELS the warning's delayed row, and without it a fifteen-minute delay was just a late mail | | **Passive income** | `uo.vendor.sale` | `owner` | **Patch-tier only** — see §8.6's corrected row; dormant on a shard that declined the patches, and the seeded rule's description must say so | | **Personal security** | `uo.account.login_failed`, `uo.account.unlinked` | `owner`, ceiling `owner` | `uo.link.requested` was a third here and is carved out (decision 4) | | **Personal milestone** | `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | `owner`, opt-in | The two death triggers are a killfeed some players want and most do not — both ship `enabled = 0` and default `off` per channel | @@ -3268,8 +3268,9 @@ and 5a/5b precedent: regenerated. The acceptance that belongs here is the catalogue-shaped half: **no staff- or admin-ceiling trigger appears by name in a player's preferences catalogue**, and `check:modules` proves core gained no UO identifier. -- **11b — the seeded templates, the seeded rules, and the live walk.** **25** rules, all - `enabled = 0` — the twenty-fourth is `uo.governor.appointed`, added by decision 10 below. Sixteen +- **11b — the seeded templates, the seeded rules, and the live walk.** **26** rules, all + `enabled = 0` — the twenty-fifth is `uo.governor.appointed` (decision 10 below) and the + twenty-sixth is `uo.house.refreshed`, which the walk itself added (decision 11). Seventeen in-universe bodies on two channels each (decisions 8 and 9), and core's `notify.event` + `inapp.event` + the structural projection for the nine that stay plain — §4.6.1 property 1 is precisely the claim that a trigger needs no authoring, and the nine are the test of it that @@ -3392,7 +3393,7 @@ are not the same text. Two triggers is also what lets an operator run the announ letter off, or the reverse. **This takes the phase to 25 triggers**, and the count in §8.6 and in 11a's summary should be read -that way from here. It does not take another `MODULE_API` bump: a trigger declaration is module data, +that way from here — **26 after decision 11 below added `uo.house.refreshed`**. It does not take another `MODULE_API` bump: a trigger declaration is module data, and 1.9.0 is decision 7's. #### What building it found @@ -3478,6 +3479,115 @@ decision-8 boundary stated as a check rather than as a promise. **Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point — which is a materially stronger claim now that the module registers two dozen UO-named triggers. +#### Three decisions taken during the live walk (2026-09-01), and what it found + +The walk is the last piece of 11b and it was not a formality: it found **six defects, four of them in +code shipped by earlier phases**, and three of the fixes were decisions rather than repairs. None of +the six was visible in a unit test, and the reason is the same every time — each is a disagreement +between two things that agree with each other in a fixture. + +| | Question the walk raised | Decision | +|---|---|---| +| 11 | `uo.house.idoc_warning` ships `delay_seconds: 900` and nothing could cancel it | **add `uo.house.refreshed`** — a 26th trigger, with a body and a seeded rule of its own | +| 12 | a rule with a cooldown delivered on exactly ONE of its channels | **`channel` joins the cooldown key** — a cooldown is per delivery, not per occasion | +| 13 | `uo.vendor.expiring` could not fire, because the market sweep does not diff fees | **widen `BridgeMarket.Signature()`** with `exempt` + `periodsRemaining` | + +**Decision 11 — the delay had no cancel path for the GOOD outcome.** §4.2a is explicit that a delay +without a cancellation is just a late mail, and the fifteen minutes on the warning exist so a player +who repairs the house is never told it is in peril. The rule's `cancel_on` named only +`uo.house.collapsed` — the outcome where the warning is pointless — and nothing named the outcome +where it is *wrong*, because the mapper deliberately returned early on every transition that was not +a late stage, so a refresh reached the engine as silence. The wire had carried it all along. It ships +with a body as well as the cancellation (the org lead's call): an office that only ever writes when +something is wrong teaches its reader to dread the seal, and a notice that the ledger is set right is +the cheapest possible way not to. The cancellation is read off the WARNING's rule, so an operator who +wants it without the reassurance simply leaves the new rule off — which, every seeded rule shipping +disabled, is the default. + +> **`Ageless`, not only `LikeNew`, and this is a ServUO fact worth not re-deriving.** A condemned +> house cannot be refreshed at all — `BaseHouse.RefreshDecay()` returns false for +> `DecayType.Condemned` — so the rescue is the owner logging back in. Their NEWEST house then becomes +> `AutoRefresh` and reads **`Ageless`**; an older one becomes `ManualRefresh` and reads **`LikeNew`**. +> Both mean "out of danger", and reading only the second misses the common case. The live rig produced +> `Ageless`. + +**Decision 12 is the one with the widest blast radius, and it had been latent since Phase 4a.** +`cooldownsDb.claim` is called INSIDE the engine's per-channel loop and its key was +`(rule, user, subject)`, so the first channel of a rule claimed the cooldown and every later one was +reported as `cooled`. `inapp` is ranked first on purpose (so `push` can reference the inbox row it +writes), which means a rule naming email + in-app delivered the inbox item and **silently never the +mail** — including core's own `news.post` rule, which names three channels and carries an hour's +cooldown. Decision 8 requires the letter and the inbox item to fire together, so every in-universe +body this phase wrote was unreachable behind its own in-app twin. + +Claiming once per user *before* the channel loop was the alternative and was refused: it makes the +cooldown a per-occasion limit, and an operator who sets "one a day about this house" means one mail +and one inbox item, not one of the two. So `channel` joins the PRIMARY KEY, migrated in place behind +a guarded `DROP PRIMARY KEY` — MariaDB has no conditional form of a key change, so replaying the +schema file would fail on every boot after the first without the `information_schema` guard. Existing +rows keep `channel = ''` and expire on their own interval: one stale cooldown per (rule, user, +subject) is a better trade than dropping the table, which would let a storm through. + +**Decision 13 is a defect in the shard plugin, and Phase 10 is where it was introduced by omission.** +`BridgeMarket.Signature()` diffs shop name, owner, map, coordinates and the item/price list — and v5 +added a `fees` block that it never learned about. So a vendor quietly running out of gold changes +nothing the sweep compares, emits no frame, and the trigger whose entire subject is a vendor running +out of gold can fire only by coincidence: when somebody happens to reprice an item on a shop that is +already broke. Proved on the rig by setting a vendor's held gold to zero and watching no frame +follow. The fix carries the DERIVED values (`exempt`, `periodsRemaining`) rather than the raw ones — +an integer division moves only when the shard's own answer to "is this vendor in danger" moves, while +`holdGold` changes on every sale and `nextPayAt` on every tick, either of which would re-emit a fat +listing frame for a shop whose listings did not change. No new wire field and **no `PROTOCOL_VERSION` +bump**: it changes when an existing frame is sent, not its shape. + +**Four more defects, each fixed without a decision because the right answer was a fact rather than a +choice:** + +- **Every module-seeded rule failed to insert.** `checkSeedRule` validated `max_sends_per_hour` and + then dropped it from the normalized rule; the column is NOT NULL, so all 25 rules failed at boot — + loudly, on a real database, and on no fixture. The registry test asserted the REJECTION of a bad + ceiling and never that a good one survives. It now asserts the normalized rule against the insert's + own column list, so the next field added is covered the day it is added. +- **Every `owner`-audienced trigger resolved to nobody.** `resolveTarget` read `link.user_id` where + the model's `toSafe` returns `userId`, so the flagship family — houses, vendors, logins, unlinks, + deaths, the governor's letter — reached no one, and looked exactly like the ordinary + unlinked-account case, which the code treats as normal and deliberately does not log. **The test + fake returned `user_id` and therefore agreed with the bug**, while `shardStreams.test.js`'s fake + next door — same model, and the path `shardEngagement.js` says it copies — returned `userId`. The + fake is now built by running the real `toSafe` over a stubbed row. +- **Every call-to-action in every in-universe body was a dead link**, from two independent mistakes + with one symptom. The client router prefixes a module's routes with its **ID** (`/uo/houses`), not + with `module.json`'s `mounts` (`/shard/…`), so every declared `example` and every hard-coded href + was a 404 — and not one of the eight `url` variables was ever populated by the mapper, so the + buttons rendered with an empty href and vanished from the text part altogether. Both now read + `config/clientPaths.js`, and two tests close it: every `url` example must name a route the module + mounts, and every declared `url` variable must actually be supplied on the path that emits its + trigger. +- **A raw wire timestamp was signing off the Merchants' Guild's letter** — + `2026-09-02T04:06:43.8397548Z`, mid-sentence. Core has no interpolation filters by design, so the + readable form is assembled in the mapper and arrives as its own variable; the machine value stays + exactly as it was, because an operator writes `is at most` conditions against it. + +**What the walk proved, rung by rung, against real ServUO + the release sidecar + a live SMTP +catcher.** A `Greatly` transition produced **one** notification to the linked owner on both channels +and nothing to a second linked player who was subscribed; the next transition inside the day was +cooled; the owner logging back in **cancelled both pending rows** and sent the reassurance letter; a +vendor drawn to its last period warned its owner once and was cooled on the second crossing; a failed +game login over a real socket produced one notice and a **successful** one produced none; three +genuine shutdown/boot cycles produced **one** downtime fan-out and two fully-cooled ones; a governor +seated on a live `city.update` produced **both** the town's bulletin (six subscribers) and the crown's +letter (the winner alone); a real staff action driven through the sidecar reached the admin and +**neither the editor nor the moderator**, both of whom were subscribed; and the rule editor refused +`staff`, `authenticated` and `everyone` on an `admin`-ceiling trigger while accepting `admin`. + +> The three cycles took about three minutes rather than the acceptance's "one minute", because a real +> ServUO boot is roughly fifty seconds. The property is unchanged: the rule's cooldown is an hour. + +The walk is repeatable. `servuo-plugins/tools/scaffolding/BridgeRigDriver.cs` drives the shard from +outside the game over a polled command file, which is what a walk needs and what no existing probe +gave — every other one runs a fixed script at boot or from an in-game client, and ServUO's console +takes a fixed verb set, so `[p5probe` cannot be typed at a headless shard at all. + --- ### Phase 12 — `runicgateway.com`: the public site and the docs journey