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`