From efce1d88aad184c73063fab08e1c4f50c18df094 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 31 Aug 2026 11:06:34 -0500 Subject: [PATCH 1/2] docs(engagement): widen Phase 10 to three wire enrichments and Phase 11 to the full trigger catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scope decisions taken by the org lead on 2026-08-31, before any Phase 10 code, plus the three factual corrections that finding them out produced. Phase 10 — the protocol bump now carries three enrichments, not one. The argument is specific to this phase: a bump costs a sidecar release, a republished bundle and an operator update on every shard, so a field left out does not cost a follow-up commit, it costs a second bump with the same lead time and a split operator population. The two additions: * player-vendor fee state on vendor.listing (ownerAcct, holdGold, chargePerDay, daysRemaining), because uo.vendor.expiring is the same "owned asset at risk with a deadline" shape as the flagship and today has neither an address nor a deadline; * a post-decision account.login.result, because EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision. Moving vendor.sale out of the opt-in patch tier was explicitly declined. Phase 11 — ships every checked row of 8.6 rather than a single rule, carving out uo.market.item_listed (a saved search; no per-user query store exists). ~23 triggers grouped by the audience kind each family exercises, since exercising the ceiling lattice at scale is the point rather than volume of mail. Every rule still ships enabled = 0 per Q3. Notes that the phase will likely want an 11a/11b split on the 4a/4b precedent, to confirm at its start. Three corrections to 8.6, each verified against the emitters rather than the table: * uo.vendor.sale is real and does carry ownerAcct, but lives in servuo-plugins/ patches/ (opt-in, verified only against ServUO 57.4) — dormant, not broken, on a shard that declined the tier; * uo.vendor.expiring had no data at all, not merely no mapper — vendor.listing carries ownerSerial/ownerName and nothing carries held gold or daily charge; * uo.account.login_attempt could not have been built as described — it would have mailed "someone tried to log into your account" on every successful login. Renamed uo.account.login_failed so the id cannot be misread again. Also amended: the status header, scope decision 3, and 6.0b's documentation assignment for rows 10 and 11 (v5.md now earned; the patch-tier caveat is an operator-facing doc obligation; runicgateway.com's capability claim changes when "one rule" becomes "the catalogue"). Co-Authored-By: Claude --- website/ENGAGEMENT.md | 194 +++++++++++++++++++++++++++++++++--------- 1 file changed, 155 insertions(+), 39 deletions(-) diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index b83f0be..c205f62 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -7,7 +7,9 @@ docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Ph docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 + runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is -still design. The scope decisions +still design. **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:** +the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of +§8.6** rather than a single rule. The scope decisions below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question), **Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase @@ -24,7 +26,10 @@ none. 1. **The in-app channel is in scope.** It does not exist today and has to be built, not adapted. 2. **The Teams notification pipeline is generalized and migrated onto the new system**, not built beside it. -3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION` bump. +3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION` + bump. **Widened 2026-08-31:** that single bump now carries **three** enrichments (house decay, + player-vendor fee state, and a post-decision login result), because a second bump would cost another + release + bundle + operator update. See Phase 10. 4. **Gmail OAuth2 is removed, not retained as a transport.** SMTP is the baseline; the OAuth2 consent flow, its two routes, its borrowed Google client and its stored refresh token all go. See §1.2a for what that deletes and §6/Phase 1 for the operator cutover it forces. @@ -1359,8 +1364,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i | **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase | | **8** In-app (Android) ✅ | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase | | **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 | -| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` | -| **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` | +| **10** Protocol bump | `link/INTEGRATION.md` §Housing **and §Market** (tables + examples) + the new `account.login.result` row · `link/PLAN.md` §5/§7 · **a `link/v5.md`** — the bump now carries three enrichments across three subsystems, which is what earned v3 and v4 their own design docs | `servuo-plugins/overlay.toml` · `servuo-plugins/patches/README.md` — `vendor.sale` stays in the tier and the docs must say what that costs · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` | +| **11** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship | `module-uo/README.md` · **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims | | **12** Public site | — | **`runicgateway.com`**, in full — see the phase | | **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability | @@ -3000,47 +3005,133 @@ a bounce handler today. --- -### Phase 10 — Protocol bump: `house.decay` enrichment *(parallel from day one)* +### Phase 10 — Protocol bump: three wire enrichments *(parallel from day one)* -Four repos plus the overlay declaration, per CLAUDE.md's "The bridge is a contract": +**Scope widened 2026-08-31, by the org lead, before any code.** The phase was written as the +`house.decay` enrichment alone. It now carries **three** enrichments, for one reason that is a property +of this phase and of no other: **a protocol bump costs a sidecar release, a republished bundle and an +operator update on every shard.** A field left out here does not cost a follow-up commit — it costs a +*second* bump with the same three-part lead time, and an operator population split across two protocol +versions in the meantime. So everything the engagement workstream is known to need from the wire is +decided **now** and rides the single v4 → v5 bump. + +The two additions come from Phase 11's expanded trigger set (below). Each was verified against the +emitters rather than assumed, and each closes a gap that §8.6 did not know it had: + +- **Player-vendor fee state.** `uo.vendor.expiring` is the same shape as the flagship — an owned asset + at risk, with a real deadline — and is the second-strongest driver in the catalogue. But + `vendor.listing` carries `ownerSerial` and `ownerName` and **no `ownerAcct` and no fee state at + all**, so the trigger can today neither be addressed to a person nor know that anything is expiring. +- **A login *outcome*.** `EventSink.AccountLogin` is a pre-decision veto hook — the emitter's own + comment says so ("Fires before the auth decision, so this is an attempt, not a result"). It therefore + fires on every **successful** login too, and `uo.account.login_attempt` as §8.6 describes it + ("someone tried to log into your game account") cannot be built on it: it would mail a security alert + every time the player themselves logged in. This is the one addition that is a new emitter rather + than new fields on an existing one. + +**Explicitly NOT in this bump:** moving `vendor.sale` out of the opt-in patch tier. It stays where it +is (see §8.6's corrected row), which leaves `uo.vendor.sale` dormant on a shard that declined the +patches — a documentation obligation, not a protocol one. | Repo | Change | | --- | --- | -| `servuo-plugins/` | `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) | -| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitter** | -| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); houses board carries the new fields | -| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version | -| `docs/` | `docs/link/INTEGRATION.md` §Housing table + example, `docs/link/PLAN.md` §5/§7 | +| `servuo-plugins/` | **(a)** `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) · **(b)** `BridgeMarket`'s `vendor.listing` gains `ownerAcct`, `holdGold`, `chargePerDay`, `daysRemaining` · **(c)** a new post-decision `account.login.result` (`acct`, `accepted`, `rejectReason`, `ip`) beside the existing pre-decision attempt | +| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitters** | +| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); the houses board carries the new decay fields; `account.login.result` is a forwarded kind like any other — the sidecar stays a dumb forwarder | +| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version; **`shardVisibility.js`'s `KIND_FEATURE` gains `account.login.result`** — rule 2 fails an unmapped kind closed to admin-only, which is the right answer for a frame carrying an IP, but it must be *chosen* here rather than inherited by accident | +| `docs/` | `docs/link/INTEGRATION.md` §Housing and §Market tables + examples, `docs/link/PLAN.md` §5/§7 | -**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not the -PowerShell stub) shows a real transition carrying the new fields; a v4 overlay paired with a v5 sidecar -is **refused by the installer**, not mis-parsed; CI publishes a bundle whose manifest pairs v5 with v5. +**Two things the emitter work must not get wrong**, both following from §0.3's finding about how +ServUO actually decays: + +- `estimatedCollapse` is exact **only** at IDOC, because dynamic decay draws each stage's duration at + random when the stage is entered. Emitting it at an earlier stage would publish a guess as a fact. +- `daysRemaining` for a vendor **is** exact (`HoldGold / ChargePerDay`), unlike the house. The two + fields must not be documented as though they carried the same confidence — a template that says + "your house collapses on the 4th" and one that says "your vendor is dismissed in 2 days" are making + very different promises. + +**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not +the PowerShell stub) shows **(a)** a real decay transition carrying the new fields, **(b)** a seeded +player vendor whose listing carries a fee state that falls as its held gold is drawn down, and **(c)** a +*failed* game login producing `account.login.result accepted:false` where a successful one produces +`accepted:true`; a v4 overlay paired with a v5 sidecar is **refused by the installer**, not mis-parsed; +CI publishes a bundle whose manifest pairs v5 with v5; and the five-rung shard visibility walk still +shows no leak, with `account.login.result` reaching **admin only**. **Note:** this phase's lead time is a release plus a bundle plus an operator update, which is why it starts early and lands independently. --- -### Phase 11 — module-uo's triggers and the first real rule +### Phase 11 — module-uo's triggers: the full catalogue, and core's `news.post` -`module-uo` registers `uo.house.idoc_warning` (and siblings), emits from `shardIngest`, and ships the -"greatly damaged" mapping. **The `Greatly` transition mapping needs no protocol change** and can ship -with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse` -until the v5 overlay is deployed, which the `required: false` declaration already permits. +**Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo` +registers `uo.house.idoc_warning` "(and siblings)", and Part 8's preamble used to say "Phase 11 ships +exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with one +carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment, +and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here. -**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is -declared with no caller, so a rule naming it can never fire; wiring it is one `ctx.events.emit`-shaped -call in `announceIfNewlyPublished`, gated on the same job-id transition signal the push already uses. -**The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a -channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person -notification. What the emit replaces is the raw `pushDispatch.publish` beside them, and Q9's continuity -question has to be answered before it does. +**What that is worth, and it is not "more mail".** One trigger proves that a module can register a +trigger. Twenty-odd triggers spanning five audience kinds are the first real test of the things Parts +3–5 asserted and nothing has yet stressed: that the ceiling lattice holds when most triggers are *not* +public, that `check:modules` still finds no UO identifier in core when the module is this large, and +that a player's preferences screen stays legible when the catalogue is real rather than a demo. Every +rule ships `enabled = 0` (Q3), so this is a catalogue an operator turns on, not a switch that floods +anyone. -**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to +**The carve-out: `uo.market.item_listed` does not ship here**, for the reason §8.6 already gives — it +is a *saved search*, not a trigger. Its audience is "users whose stored query matches this listing", +and no per-user query store exists anywhere in the tree. It is its own workstream on top of this one. +`vendor.listing` remains ingested; nothing is lost by waiting. + +**Two rows ship only because Phase 10 does.** `uo.house.idoc_warning`'s enrichment and +`uo.vendor.expiring` both need v5 fields; `uo.account.login_attempt` needs v5's new +`account.login.result` emitter and is renamed **`uo.account.login_failed`** to say what it actually +is. Everything else is mapping-only and can ship whether or not the bump has landed — the flagship's +`Greatly` mapping included, which **needs no protocol change** and can ship with Phase 6 if Phase 10 +is still in flight, the trigger simply omitting `nextStage`/`estimatedCollapse` until the v5 overlay is +deployed, exactly as the `required: false` declaration already permits. + +**The set, grouped by the audience kind each family exercises** — which is the point of grouping them +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` | +| **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`, `uo.link.requested` | `owner`, ceiling `owner` | `uo.link.requested` is also the linking funnel: a player ran `[link` in game, finish it on the site | +| **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 | +| **Social / civic** | `uo.guild.joined`, `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry | +| **Come online now** | `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down` | `subscribers` | **`uo.server.up`/`down` is the cooldown table's stress test** — a flapping shard emits both repeatedly. Hard per-rule cooldown, not a per-send one | +| **Leaderboard** | `uo.points.rank_changed` | `subscribers`, `owner` | Fires both ways (you entered a top N; you were pushed out) | +| **Staff-facing** | `uo.page.new`, `uo.cheat.detected`, `uo.audit.staff_action` | ceiling `staff` / `admin` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism | +| **Operator-facing** | `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant | + +**Also core's own `news.post` emitter, which this phase's title has always understated** (§7.1 Q9). +`news.post` is declared with no caller, so a rule naming it can never fire; wiring it is one +`ctx.events.emit`-shaped call in `announceIfNewlyPublished`, gated on the same job-id transition signal +the push already uses. **The announce legs and the post hooks are untouched** — a module's town-crier +leg is a delivery to a channel of the deployment and its news-gump hook is state mirroring, neither of +which is a per-person notification. What the emit replaces is the raw `pushDispatch.publish` beside +them, and Q9's continuity question has to be answered before it does. + +**On the size of this phase.** It is now the largest in the workstream: ~23 trigger declarations, their +audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's +nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings — +server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and +that split should be confirmed with the org lead at the start of the phase rather than assumed here. + +**Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or +admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to `Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window -cancels the pending mail; **a news post published on the rig reaches a rule, and the town-crier leg and -every registered post hook still fire exactly as they did.** -**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point. +cancels the pending mail; a player vendor drawn down to its last day produces one `uo.vendor.expiring` +to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one +produces none; a shard restarted three times in a minute produces **one** downtime mail, not six; and +**a news post published on the rig reaches a rule, with the town-crier leg and every registered post +hook still firing exactly as they did.** +**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 twenty-odd UO-named triggers. --- @@ -3370,9 +3461,11 @@ Nothing here is "documentation to do at the end" — a phase is not done until i ## Part 8 — What the system could be used for -A catalogue, not a commitment. Its purpose is to check that the design in Parts 3–5 is general enough, -and to show what an operator gets for the phases they pay for. **Nothing here is scheduled**; Phase 11 -ships exactly one rule. +Written as a catalogue, not a commitment — to check that the design in Parts 3–5 is general enough, and +to show what an operator gets for the phases they pay for. **§8.6 is no longer that.** On 2026-08-31 the +org lead scheduled **every ✅ row of §8.6 into Phase 11**, so for the `module-uo` section the burden of +proof has inverted: a row that does not ship needs a reason recorded in the phase. The rest of Part 8 — +§8.1–8.5 and §8.7 — remains unscheduled and stays a catalogue. Read the availability column as: **✅** the data exists today and a rule could be written the day the engine lands · **⚠** the data exists but needs a mapper or a resolver · **❌** needs new plumbing @@ -3464,16 +3557,21 @@ which is the one legitimately campaign-shaped use and the one most likely to be ### 8.6 Module events — `module-uo`, grounded in what the shard actually emits -Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` would register; -core stays ignorant of every word in it. +Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` registers; +core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except +`uo.market.item_listed`, carved out there with its reason. + +**Three rows were corrected on 2026-08-31**, when the set was scheduled and each claim was checked +against the emitters rather than against this table. Two of the corrections are what widened Phase 10 +from one wire enrichment to three; the third is a caveat that has to reach the operator, not a defect. | Trigger | Wire source | Data | Why anyone cares | | --- | --- | --- | --- | | `uo.house.idoc_warning` | `house.decay` → `Greatly` | ⚠ mapping only (the enrichment is Phase 10) | **The flagship.** Your house is decaying; log in or lose it and its contents | | `uo.house.collapsed` | `house.decay` → `Collapsed`, `house.remove` | ✅ | The bad news, so it is not a surprise | -| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) | Your player vendor sold something, for how much | -| `uo.vendor.expiring` | `vendor.listing`, vendor fees | ⚠ | Your vendor is about to be dismissed for unpaid fees | -| `uo.account.login_attempt` | `account.login.attempt` | ✅ (already a personal stream) | Someone tried to log into your game account, from where | +| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) — **but patch-tier only**, see below | Your player vendor sold something, for how much | +| `uo.vendor.expiring` | `vendor.listing` + **v5 fee fields** | ⚠ → ✅ **after Phase 10**; `vendor.listing` carries no `ownerAcct` and no fee state today | Your vendor is about to be dismissed for unpaid fees | +| `uo.account.login_failed` | **v5 `account.login.result`** | ⚠ → ✅ **after Phase 10**; the existing `account.login.attempt` is pre-decision, see below | Someone tried to log into your game account, and failed, and from where | | `uo.character.death` / `murdered` | `player.death`, `player.murdered` | ✅ | Opt-in; a killfeed some players want and most do not | | `uo.skill.capped` | `skill.gain` where `base == cap` | ✅ | You hit the cap in a skill — a genuine milestone | | `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail | @@ -3494,7 +3592,25 @@ core stays ignorant of every word in it. | `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds | | `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend | -**Three of these are worth calling out as design pressure on Parts 3–5:** +**Three rows as originally written were wrong, and finding out cost one sweep of the emitters:** + +- **`uo.vendor.sale` is real and does carry `ownerAcct` — but it lives in `servuo-plugins/patches/`, + the opt-in patch tier**, verified only against ServUO 57.4. It is a `PlayerVendorSale` EventSink the + patches *add* to core plus a subscriber that reads it, which is why it is absent from `overlay/`. A + shard that declined the tier, or runs another ServUO version, emits this kind never — so the rule is + silently dormant there rather than broken. Phase 10 deliberately does **not** try to move it into the + overlay; the obligation is that the seeded rule and the operator docs say which tier it needs. +- **`uo.vendor.expiring` had no data at all, not merely no mapper.** `vendor.listing` carries + `ownerSerial` and `ownerName` — no `ownerAcct`, so it cannot even be addressed to a website user — + and nothing anywhere on the wire carries a vendor's held gold or daily charge. Phase 10 adds all four. +- **`uo.account.login_attempt` could not have been built as described.** `EventSink.AccountLogin` is a + pre-decision veto hook; the emitter's own comment says "Fires before the auth decision, so this is an + attempt, not a result". A rule on it would have mailed "someone tried to log into your account" every + time the player logged in successfully — the exact inversion that makes people distrust security + mail. Phase 10 adds a post-decision `account.login.result`, and the trigger is renamed + **`uo.account.login_failed`** so its id cannot be misread again. + +**Three others are worth calling out as design pressure on Parts 3–5:** - **`uo.market.item_listed` is a *saved search*, not a plain trigger.** The audience is "users whose stored query matches this listing", which `audience: 'computed'` covers but a per-user query store -- 2.49.1 From 6304c42dfbc5b2a106419bc0532c745d7d3db3d1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 31 Aug 2026 19:19:09 -0500 Subject: [PATCH 2/2] =?UTF-8?q?docs(link):=20protocol=205=20=E2=80=94=20th?= =?UTF-8?q?e=20decay=20schedule,=20vendor=20fee=20state=20and=20account.lo?= =?UTF-8?q?gin.result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design of record for the bump, plus the reference updates it obliges. Engagement Phase 10, as built. link/v5.md is new. Three enrichments in one bump, because a bump costs a sidecar release, a republished bundle and an operator update on every shard — a field left out costs a whole second round of that, not a follow-up commit. a. house.decay gains ownerName and a nested `schedule`. estimatedCollapse is present ONLY where it is exact: under dynamic decay each stage's duration is drawn at random on entry, so collapse is knowable only once the house is already at IDOC; under static decay it is exact at every stage. Omitted rather than approximated — an absent field is honest where a wrong date becomes a dated promise in a player's mail. b. vendor.listing gains ownerAcct — a character name identifies nobody, only the game account joins to shard_account_links — and a `fees` block resolving PlayerVendor.PayTimer's dismissal rule into a dismissalAt instant. There is deliberately no daysRemaining: a pay period is a UO day on an old-system shard, so the name would be wrong by a factor of twelve. c. account.login.result is a NEW kind. account.login.attempt fires from a veto hook that runs before the auth decision and constructs with Accepted = true, so it fires on successful logins too and cannot carry a verdict. INTEGRATION.md: the version banner to 5, the Housing and Market field tables and examples, and the new kind beside the attempt — with the attempt's own row corrected to say why it is not a failure signal. PLAN.md: 5.1's "failed-login signals" row was wrong about what AccountLogin can tell you; 5.3 and 5.4 gain the two new signals; 5.4's dynamic-decay model gets the consequence for publishing a collapse time, and the CanDecay rule gets its testing trap. 7's frames are marked as 1.0's design sketch rather than the shipped wire, which they have not been for four versions. ENGAGEMENT.md: Phase 10 as built. Two decisions taken during the build (both field groups nested, so one admin rule governs each and a future field inherits the gate; no daysRemaining), the three visibility classifications, and the pin improvement — the schema test now asserts the declarations AGREE rather than that they all say a literal, which is the protocol-4 bug seen from the other side. Co-Authored-By: Claude --- README.md | 3 +- link/INTEGRATION.md | 95 ++++++++++++--- link/PLAN.md | 25 +++- link/v5.md | 261 ++++++++++++++++++++++++++++++++++++++++++ website/ENGAGEMENT.md | 64 ++++++++++- 5 files changed, 432 insertions(+), 16 deletions(-) create mode 100644 link/v5.md diff --git a/README.md b/README.md index 28e4e8b..4110166 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ particular game; a module is what makes it a site *for* one. | [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar | | [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design | | [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework | -| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`). **The current protocol** | +| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) | +| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**; built on `edge`, not yet released | | [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) | | [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge | | [PLAN.md](link/PLAN.md) | uo-link build plan | diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 806a2f7..9657a35 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -The current version is **4**, shipped in sidecar **v2.0.0** and overlay **v1.0.0**. +The current version is **5**. It is built but **not yet released** — the last shipped pairing is protocol 4, in sidecar **v2.0.0** and overlay **v1.0.0**. -- Every response carries an **`X-UOLink-Version: 4`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`. -- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 5`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`. +- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 4, "client_protocol": "3" } + { "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. @@ -82,6 +82,21 @@ the wire. Additive in the same shape as the previous two bumps — nothing that so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once it declares `4`. +**v5 (Protocol 5)** adds three things at once ([`v5.md`](v5.md)) — `house.decay` gains `ownerName` +and a `schedule`, `vendor.listing` gains `ownerAcct` and a `fees` block, and `account.login.result` +is a new kind carrying the verdict its long-standing `account.login.attempt` companion fires too +early to know. Three at once because a bump costs a release, a bundle and an operator update on +every shard, so a field left out costs a whole second round of that. + +Additive again: no existing field changed shape, and **no new endpoint** — every v5 addition rides +kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes, +both about ABSENCE rather than presence, because both are easy to read as an error: + +- `schedule.estimatedCollapse` is **omitted whenever it is not exactly knowable** — which, on a + dynamic-decay shard, is every stage before IDOC. +- `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a + commission vendor. Neither means "this vendor has no money". + **Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you @@ -168,7 +183,8 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s |------|--------| | `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) | | `mob.logout` | `who` | -| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) | +| `account.login.attempt` | `acct`, `ip` — an authentication attempt, fired from a sink that runs **before** the auth decision, so it fires on successful logins too. Use `account.login.result` for the verdict. No password ever leaves the shard | +| `account.login.result` | `acct`, `ip`, `accepted`, `reason` — **Protocol 5.** The verdict of the attempt above, which the attempt structurally cannot carry. `reason` is an `ALRReason` (`BadPass`, `Invalid`, `Blocked`, `InUse`, `BadComm`) and is **present only when `accepted` is false**, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind | #### Economy & commerce | kind | fields | notes | @@ -204,15 +220,36 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s #### Housing / IDOC | kind | fields | |------|--------| -| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` | +| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` | `from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign. +\* **Protocol 5.** `ownerName` is the owner's character name (`ownerAcct` is the game account, and +the only one of the two that identifies a person). `schedule` is a nested object: + +| field | meaning | +|---|---| +| `dynamicDecay` | whether this shard runs ServUO's dynamic decay (`Core.ML`). Always present | +| `nextStage` | ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock | +| `decayPeriodSec` | seconds from a full refresh to collapse; lets a reader turn `lastRefreshed` into a percentage | +| `estimatedCollapse` | ISO-8601 UTC — **present only when it is exact**, see below | + +**`estimatedCollapse` is absent far more often than not, and that is deliberate.** Under dynamic +decay ServUO draws each stage's duration at *random* when the stage is entered, so collapse is +exactly knowable only once the house is already at `IDOC` — at which point the next transition is +the collapse. Under static decay it is a pure function of `lastRefreshed + decayPeriodSec` and is +exact at every stage. It is omitted rather than approximated, because an absent field is honest +where a wrong date is a dated promise. **Treat its absence as "not knowable", never as "not yet +read"** — and never fall back to computing one yourself under dynamic decay. + ```json -{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", - "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", - "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, - "builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"} +{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC", + "map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace", + "ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002", + "schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z", + "decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"}, + "ban":{"x":1482,"y":1604,"z":0}, + "builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"} ``` #### Economy supply (periodic) @@ -523,14 +560,46 @@ shop name or location actually change. | kind | fields | notes | |------|--------|-------| -| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. | +| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `ownerAcct`*, `location{}`, `fees{}`*, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. | | `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. | +\* **Protocol 5.** `ownerAcct` is the owner's game account — `ownerName` is a character name and +identifies nobody, so this is the field that makes a shop resolvable to a person at all. + +`fees` describes ServUO's vendor dismissal rule (`PlayerVendor.PayTimer`: at each tick the charge +is compared with the funds, and the vendor is destroyed when the charge wins): + +| field | meaning | +|---|---| +| `exempt` | `true` for a commission vendor, which has no pay timer and is **never** dismissed for fees. When true, no other field is present | +| `newVendorSystem` | which of ServUO's two vendor systems is in force; it decides all three quantities below | +| `chargePerPeriod` | what is deducted at each tick | +| `funds` | gold available to pay it (`holdGold` and `bankAccount` are the raw parts) | +| `payIntervalSec` | seconds between ticks: 86400 under the new system, **one UO day (≈2 real hours)** under the old | +| `nextPayAt` | ISO-8601 UTC: the next tick | +| `periodsRemaining` | ticks survived before the one that finds the charge unpayable | +| `dismissalAt` | ISO-8601 UTC: the tick the vendor is destroyed on. **This is the field to build on** | + +**There is deliberately no `daysRemaining`**: under the old vendor system a pay period is a UO day, +so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice. +`dismissalAt` is an instant and needs no units. It assumes no further sales or deposits — but +unlike `house.decay`'s `estimatedCollapse` there is no randomness in it: given the current funds +it is exact. + +**Treat `exempt: true` and a distant `dismissalAt` as different things.** "Never dismissed" and +"dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire +ends up in an expiry warning. + ```json {"kind":"vendor.listing","serial":"0x40001234", "shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow", + "ownerAcct":"darrow_acct", "location":{"map":"Trammel","x":1421,"y":1699,"z":0, "region":"Britain","house":"Darrow's Villa"}, + "fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548, + "funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400, + "nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7, + "dismissalAt":"2026-09-08T21:01:21Z"}, "count":2,"total":2,"truncated":false, "items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1, "price":25000,"name":null,"cliloc":1023721}, @@ -1020,7 +1089,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PLAN.md b/link/PLAN.md index 5bf9b88..b9ebd1b 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -103,7 +103,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an | Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. | | Player offline | `EventSink.Logout` | low | Pair with Login. | | Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. | -| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. | +| Auth attempts | `AccountLogin`, `GameLogin` | low | IP signals for the website. **`AccountLogin` cannot give you a FAILED login**, which this row assumed it could: it is a veto hook that fires *before* the auth decision, and `AccountLoginEventArgs` constructs with `Accepted = true`, so a handler reading the verdict there reports every login as accepted. Protocol 5 adds `account.login.result`, read one Core slice later — [`v5.md`](v5.md) §2.3. | | Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. | | Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. | @@ -133,6 +133,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an | **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. | | Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. | | Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. | +| **Vendor fee state** | vendor sweep (Protocol 5) | periodic | The dismissal deadline, not just the balance. `PlayerVendor.PayTimer` destroys a vendor when `pay > totalGold`, and **both halves differ between ServUO's two vendor systems** — `ChargePerRealWorldDay` vs `ChargePerDay`, `HoldGold` vs `BankAccount + HoldGold`, a real day vs a UO day. Resolved on the shard into a `dismissalAt` instant; see [`v5.md`](v5.md) §2.2. `IsCommission` vendors have no pay timer at all. | | Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. | > ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.** @@ -144,6 +145,7 @@ Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. | +| **Decay schedule** | same sweep (Protocol 5) | with the above | `NextDecayStage`, `DecayPeriod`, and a collapse time **only where one exists** — see the note below. | Hold a `Dictionary` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot. @@ -153,6 +155,20 @@ Hold a `Dictionary` and emit only on transition. On `ServerS - **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest. - Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`. +**What that model means for publishing a collapse time (Protocol 5).** Because each stage's +duration is drawn at random *when the stage is entered*, `NextDecayStage` is exact for the next +transition and **nothing beyond it is known at all**. A collapse time is therefore exact only once +the house is already at IDOC, where the next transition is the collapse. `house.decay` omits +`estimatedCollapse` everywhere else rather than approximating it — the field's absence means "not +knowable", never "not yet read". On a shard where `DynamicDecay.Enabled` is false the dead code +above is live instead, and collapse is exact at every stage; the frame carries `dynamicDecay` so a +consumer can tell which regime it is reading. [`v5.md`](v5.md) §2.1. + +**The `CanDecay` rule above is also the main trap when TESTING this.** Forcing a stage with +`SetDynamicDecay` on an `AutoRefresh` house does nothing observable: the `DecayLevel` getter calls +`ResetDynamicDecay()` and reports `Ageless`, so the forced stage is wiped before the next sweep +reads it and **no frame is emitted at all** — which looks exactly like a broken emitter. + Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`. ### 5.5 Combat, death, PvP @@ -233,6 +249,13 @@ Three edits, then the bridge stays pure-subscription: Newline-delimited JSON, one object per line, `serial` as the primary key. +> **The frames below are 1.0's design sketch, not the shipped wire.** They have drifted in the four +> versions since — `house.decay` in particular ships a flattened `ownerSerial`/`ownerAcct`/`ownerName` +> rather than an `owner` object, and from Protocol 5 its `nextStage` lives inside a nested `schedule` +> alongside `decayPeriodSec`, `dynamicDecay` and a conditional `estimatedCollapse`. The normative +> field lists are [`INTEGRATION.md`](INTEGRATION.md) §Event catalog, with each bump's rationale in +> [`PROTOCOL_2.md`](PROTOCOL_2.md), [`v3.md`](v3.md), [`v4.md`](v4.md) and [`v5.md`](v5.md). + ### Outbound (shard → sidecar) ```jsonc diff --git a/link/v5.md b/link/v5.md new file mode 100644 index 0000000..8c3fafa --- /dev/null +++ b/link/v5.md @@ -0,0 +1,261 @@ +# Protocol 5 — Three enrichments, one bump + +**Status:** Built, on `edge`. Not yet cut over to `main`, so not yet released. +**Date:** 2026-08-31 +**Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`INTEGRATION.md`](INTEGRATION.md) (website API). +**Driven by** [`../website/ENGAGEMENT.md`](../website/ENGAGEMENT.md) Phase 10. + +--- + +## 1. Why three things at once + +Every previous bump did one thing. This one does three, and the reason is a property of the bump +itself rather than of the features: + +**A protocol bump costs a sidecar release, a republished bundle, and an operator update on every +shard.** A field left out does not cost a follow-up commit — it costs a *second* bump with the same +three-part lead time, and an operator population split across two protocol versions until the second +one finishes propagating. So everything the engagement workstream is known to need from the wire was +decided before any of it was written, and all of it rides v5. + +The three were not picked from a wish list. Each is a field that a **specific Phase 11 trigger cannot +be built without**, and in two of the three cases that was discovered by reading the emitters rather +than by reading the plan, which had recorded both as already possible. + +| # | Enrichment | The trigger that needs it | What was actually there | +|---|---|---|---| +| a | `house.decay` gains `ownerName` + a `schedule` | `uo.house.idoc_warning` — the flagship | The frame could say a house had *become* IDOC and nothing about when it ends | +| b | `vendor.listing` gains `ownerAcct` + `fees` | `uo.vendor.expiring` | `ownerSerial` and `ownerName` only, and **no fee data anywhere on the wire** — the plan had this as "needs a mapper" | +| c | `account.login.result`, a new kind | `uo.account.login_failed` | `account.login.attempt` fires **before the auth decision**, so it fires on successful logins too | + +### 1.1 What was deliberately left out + +**`vendor.sale` stays in the opt-in patch tier.** It is real, and it already carries `ownerAcct` — +but it lives in `servuo-plugins/patches/`, verified only against ServUO 57.4, because it needs a +`PlayerVendorSale` EventSink that the patches *add* to core. Moving it into `overlay/` was considered +and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core +patch as though it were an overlay file. The consequence is a documentation obligation, not a +protocol one — `uo.vendor.sale` is a trigger that is **silently dormant on a shard that declined the +tier**, and the rule that names it has to say so. + +--- + +## 2. The shard side + +### 2.1 `house.decay` — a schedule, and only where it is knowable + +`BridgeSweeps.WriteDecay` gains `ownerName` and one nested object: + +```json +{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC", + "name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002", + "schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z", + "decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"}, + "map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"} +``` + +**`estimatedCollapse` is present only when it is exact**, and that is the whole design of this field. +ServUO has two decay implementations and they differ in how knowable the future is: + +- **Dynamic decay** (`DynamicDecay.Enabled`, i.e. `Core.ML`) draws each stage's duration at *random* + when the stage is entered — `BaseHouse.SetDynamicDecay` calls `DynamicDecay.GetRandomDuration`. So + `NextDecayStage` is exact for the **next** transition and nothing beyond it is known at all. + Collapse becomes exact only once the house is already at IDOC, because then the next transition + *is* the collapse. +- **Static decay** (`GetOldDecayLevel`) is a pure function of `LastRefreshed` and `DecayPeriod`, so + collapse is exact at **every** stage — there is no randomness to wait out. + +Emitting a dynamic-decay house's collapse time at, say, `Fairly` would publish a guess as a fact, and +on the website's side that becomes a dated promise in a player's mail. So it is **omitted rather than +approximated**. An absent field is honest where a wrong date is not, and the consumer's +`required: false` declaration already permits the absence. + +`dynamicDecay` is emitted unconditionally so a reader can tell the two regimes apart, rather than +having to infer them from which fields arrived. + +**The nesting is load-bearing, not cosmetic.** The website's visibility projection matches literal +JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys +would be four rules that drift apart — the same argument that made `vendor.listing`'s `location` +nested in v3. + +### 2.2 `vendor.listing` — an owner who can be found, and a deadline + +Two additions, and the first matters more than it looks: + +**`ownerAcct`.** The frame has carried `ownerName` since v3, but a character name joins to nothing: +the website's `shard_account_links` is keyed by the game **account**. Until now a vendor row named an +owner the site could not resolve to a person, which is why a per-owner rule was impossible however +much fee data existed. + +**`fees`**, a nested object describing `PlayerVendor.PayTimer`'s dismissal rule. That rule is +`if (pay > totalGold) Destroy()` at each tick — and *both halves of the comparison differ between +ServUO's two vendor systems*: + +| | charge | funds | interval | +|---|---|---|---| +| `NewVendorSystem` | `ChargePerRealWorldDay` | `HoldGold` | 1 real day | +| old system | `ChargePerDay` | `BankAccount + HoldGold` | 1 UO day (`Clock.MinutesPerUODay`, ≈2 real hours) | + +Both are resolved on the shard rather than left for the sidecar or the website to reconstruct, +because reconstructing them anywhere else is a second implementation of a rule that lives in +`PlayerVendor`. + +```json +"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504, + "holdGold":82504,"bankAccount":0,"payIntervalSec":86400, + "nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7, + "dismissalAt":"2026-09-08T21:01:21.6883320Z"} +``` + +Two naming decisions worth recording, because the obvious spellings are both wrong: + +- **There is no `daysRemaining`.** On an old-system shard a pay period is a UO day, so a field with + "days" in its name would be off by a factor of twelve on exactly the shards least likely to notice. + The wire carries `periodsRemaining` plus the interval that gives it meaning, and resolves the + arithmetic into `dismissalAt` — an *instant*, which needs no units at all. +- **`exempt` is not "a very long time left".** A commission vendor (`IsCommission`) has no `PayTimer` + and is never dismissed for fees. It reports `{"exempt": true}` and no schedule, so a surface + rendering "never" can tell it apart from one rendering "in 400 days". + +`dismissalAt` assumes no further sales or deposits, exactly as a bank-balance projection does. Unlike +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. + +### 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 +`PacketHandlers.AccountLogin` invokes **before** it branches on `e.Accepted`. The emitter's own +comment has said so since it was written: *"Fires before the auth decision, so this is an attempt, +not a result."* + +The verdict is set by the handlers themselves — `Server.Misc.AccountHandler` is the one that checks +the password and sets `Accepted`/`RejectReason` — so inside our handler the verdict does not exist +yet. `AccountLoginEventArgs` constructs with `Accepted = true`, which means a naive read there +reports **every** login as accepted, including the ones about to be rejected. + +That is why "someone tried to log into your game account" could not be built on the attempt: a rule +on it would have mailed a security alert every time the player logged in successfully — the exact +inversion that makes people stop trusting security mail. + +```json +{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"} +{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true} +``` + +**How it reads the verdict.** `Timer.DelayCall(TimeSpan.Zero, …)` over the args object: one Core +slice later, `InvokeAccountLogin` has returned and the verdict is final. This needs no core patch, +and — the reason it is preferred over simply subscribing late — it does not depend on handler +**subscription order**, which ServUO does not define and which a shard's own scripts can change. + +Three details that are deliberate: + +- **`reason` is omitted on an accept.** `ALRReason`'s zero value is `Invalid`, so emitting it + unconditionally would put a plausible-looking failure reason on every successful login. +- **The IP is resolved inside the handler**, not in the deferred call: `AccountLogin_ReplyRej` + disposes the `NetState` before the deferred read runs. +- **The password is never read, never logged, never emitted.** The args object carries it, so it is + held for one extra Core slice and no longer, and exactly two properties are read off it. + +--- + +## 3. The sidecar side + +`PROTOCOL_VERSION: u32 = 4` → `5` in `sidecar/src/main.rs`, and **nothing else**. + +There is **no store migration this time**, unlike v4. Every frame is persisted whole and the board +tables index only the columns they already had, so the new fields ride inside the stored JSON and the +new kind lands in `events` like any other. There is no kind allowlist to extend, either. + +That is the dumb-forwarder property doing its job (v3 §3): the +sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that +touches one constant is the *expected* cost of an additive protocol version here; v4 needing a +migration was the exception, because it added a column to a board table. + +--- + +## 4. Visibility + +Three classifications, made on the website in `module-uo`'s `shardVisibility.js`, never in the +sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently +invisible with nobody noticing. + +| Field / kind | Audience | Why | +|---|---|---| +| `house.decay` → `schedule` | **`anonymous`**, configurable | The countdown *is* the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it | +| `vendor.listing` → `fees` | **`admin`**, configurable | The only default in the `market` feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce | +| `vendor.listing` → `ownerAcct` | **admin, locked** | Rule 1, automatically: the key ends in `acct` | +| `account.login.result` | **admin, by omission** | Rule 2 — a kind absent from `KIND_FEATURE` reaches nobody below admin | + +**Why `fees` breaks the `market` pattern.** A shop's name, owner and location are already visible to +any player through the stock in-game Vendor Search gump, which is the entire argument for publishing +them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the +**owner**, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure +*and* a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in +each. + +**Why `account.login.result` is admin-only by omission rather than by mapping.** `KIND_FEATURE` is +the map of kinds that *may* leave the admin channel, and an admin can widen anything on it. There is +no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it +at all would create a door that should not exist. The omission is the decision, and there is a test +that says so by name. + +**The REST reads are unchanged.** `shard_vendors`' new columns are not in any read model's column +list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule +*is* on the read model, re-nested under `schedule` on the way out so the stored shape and the live +wire frame spell it identically — otherwise one admin rule would cover only one of the two paths, +which is the §3.6.1 failure mode v3 already recorded once. + +--- + +## 5. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | `BridgeSweeps.AppendDecaySchedule`, `BridgeMarket.AppendFees`, `BridgeEvents.EmitLoginResult` · **`overlay.toml` `protocol = 5`, in the same PR as the emitters** | +| `link` | `PROTOCOL_VERSION` → 5 | +| `module-uo` | ingest + schema (`shard_houses` ×4, `shard_vendors` ×8) · `shardVisibility.js` ×3 · the `uo_link_config` protocol pin, both declaration sites | +| `docs` | this file · `INTEGRATION.md` §Housing, §Market, §Account · `PLAN.md` §5/§7 | +| `runicgateway.com` | `platform.json.protocol` → 5 — **deferred to ENGAGEMENT.md Phase 12**, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately | + +**The pin has three declaration sites and they are checked against each other, not against a +literal.** `module-uo`'s schema test used to assert `DEFAULT 4` at each site, which is how protocol 4 +shipped with the emitters moved and one declaration left behind: every site agreed with itself. It +now reads `DEFAULT_PROTOCOL` from the model, so the assertion is "the declarations **agree**" and a +bump that misses one fails in CI instead of on an operator's install. + +--- + +## 6. Verification + +Unit tests: 470 in `module-uo/server` (16 new), 39 in the sidecar, all passing; the C# compiles +against the real ServUO 57.4 reference assemblies. + +Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles, +209k items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol5Probe.cs`. + +| Claim | Evidence | +|---|---| +| `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC. The `Fairly` and `Greatly` frames carry `nextStage` and no `estimatedCollapse`; the `IDOC` frame carries both, equal | +| `house.decay` carries the owner's character name | `"ownerName":"Zara Crowe"` on all three frames | +| the fee block is right | `funds/chargePerPeriod` floors to `periodsRemaining` on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) | +| a vendor with one tick left says so | `periodsRemaining: 0` with `dismissalAt == nextPayAt` — dismissed at the very next tick, which is the case `uo.vendor.expiring` exists for | +| the login verdict is the FINAL one | A real socket login with a wrong password → `accepted:false reason:BadPass`; with the right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** | +| the sidecar needed no change beyond the constant | `GET /health` → `"protocol":5`; every v5 field arrived through the generic forward path | +| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in `installer`: `bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused` and `doctor.rs::a_protocol_mismatch_fails_the_row`. The check is version-agnostic, so v5 needs no installer change | + +### 6.1 Two rig traps, both of which fake a broken emitter + +Recorded because each cost a rebuild and each produces *silence* rather than an error. + +- **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls + `acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so + firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password. + Only a real socket proves the accepted half (and it is the better test anyway: it also produces the + real `ip`). +- **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned` and + `ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always + `AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`, + wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world + exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down + first. diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index c205f62..c0c9bb2 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -7,7 +7,8 @@ docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Ph docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 + runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is -still design. **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:** +still design. **PHASE 10 IS BUILT (2026-08-31)** — the protocol bump; see its as-built below and +[`../link/v5.md`](../link/v5.md). **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:** the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of §8.6** rather than a single rule. The scope decisions below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on @@ -3063,6 +3064,67 @@ starts early and lands independently. --- +#### Phase 10 as built (2026-08-31) + +**Design of record: [`../link/v5.md`](../link/v5.md).** All three enrichments shipped as scoped, +and the phase found nothing that changed its shape — which is itself worth noting, because the two +*additions* to this phase existed only because reading the emitters had already contradicted §8.6 +twice. + +**Two design decisions taken during the build, neither of them in the phase text:** + +- **Both new field groups are NESTED** (`house.decay.schedule`, `vendor.listing.fees`) rather than + eleven sibling keys. The visibility projection matches literal JSON keys, so a nested group is + ONE admin rule that governs the whole group — and, more importantly, a field added inside it + later inherits the gate instead of defaulting to visible. Eleven flat keys would have been eleven + rules to keep in step. There is a test that adds an imaginary future fee field and asserts it is + still gated. +- **There is no `daysRemaining`.** The obvious name is wrong: under ServUO's old vendor system a pay + period is a UO day (`Clock.MinutesPerUODay`, ≈2 real hours), so the field would have been off by + a factor of twelve on exactly the shards least likely to notice. The wire carries + `periodsRemaining` plus the interval, and resolves the arithmetic into `dismissalAt` — an instant, + which needs no units. + +**Three visibility classifications, each chosen rather than inherited** (v5.md §4): the decay +`schedule` is `anonymous` (the countdown is the public IDOC page's content), `fees` is **`admin`** — +the only default in the `market` feature that does not reproduce prior behaviour, because a vendor's +held gold is owner-only in game and publishing it is both a new disclosure and a targeting aid — and +`account.login.result` is admin-only **by omission**, since `KIND_FEATURE` is the map of kinds an +admin may widen and there is no rung below admin an IP-plus-auth-verdict belongs on. + +**The REST reads deliberately did not change.** `shard_vendors`' eight new columns are in no read +model's column list: they exist for Phase 11's server-side trigger and reach no client at all. + +**One improvement to the pin, which is the protocol-4 bug seen from the other side.** `module-uo`'s +schema test asserted `DEFAULT 4` at each declaration site — so when the emitters moved and one site +was left behind, every site still agreed with itself and the test passed. It now reads +`DEFAULT_PROTOCOL` from the model, making the assertion "the declarations **agree**" rather than +"they all say 4", and the one-shot migration test is written once against the current version +instead of being hand-copied per bump. + +**Acceptance, all met.** 470 tests in `module-uo/server` (16 new) and 39 in the sidecar; the C# +compiles against real ServUO 57.4 assemblies. On the live rig — real ServUO, seeded world, the +**release** sidecar — a house walked Fairly → Greatly → IDOC produced `estimatedCollapse` on the +IDOC frame **and only there**; every observed vendor's `periodsRemaining` matched +`funds / chargePerPeriod`, including one at `0` whose `dismissalAt` equals its next tick; and a real +socket login produced `accepted:false reason:BadPass` for a wrong password and `accepted:true` for +the right one. **The installer needed no change** — its pairing refusal is version-agnostic and +already tested (`bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused`). + +**Two rig traps, both of which fake a broken emitter by producing silence** (v5.md §6.1): an +in-process login probe can never produce `accepted:true`, because `AccountHandler` calls +`HasAccess(e.State)` before it checks the password and a null `NetState` fails it; and forcing a +decay stage on a house that cannot decay emits **nothing at all**, because an `AutoRefresh` house — +and the owner's newest house is always `AutoRefresh` — resets the forced stage on the next read. +Both are recorded in `servuo-plugins/tools/scaffolding/README.md` beside the new +`BridgeProtocol5Probe.cs`. + +**Deferred, deliberately:** `runicgateway.com`'s `platform.json.protocol` → 5 stays Phase 12's, since +`checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red at +once. + +--- + ### Phase 11 — module-uo's triggers: the full catalogue, and core's `news.post` **Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo` -- 2.49.1