The walk is the last piece of 11b and it was not a formality. It found six
defects, four of them in code shipped by earlier phases, and none of the six was
visible in a unit test: each is a disagreement between two things that agree with
each other in a fixture.
Three of the fixes were decisions rather than repairs, all settled by the org
lead before any code:
11 `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel
it -> add `uo.house.refreshed`, a 26th trigger with a body and a rule
12 a rule with a cooldown delivered on exactly ONE of its channels
-> `channel` joins the cooldown key; a cooldown is per DELIVERY
13 `uo.vendor.expiring` could not fire, because the market sweep does not
diff fees -> widen BridgeMarket.Signature() with exempt +
periodsRemaining
Files:
website/ENGAGEMENT.md the three decisions, the four repairs, and what the
walk proved rung by rung; the 11b bullet and the §8.6
family table now read 26 triggers / 34 bodies
website/BACKEND_DESIGN.md engagement_cooldowns gains `channel` in its PRIMARY
KEY, with the migration's information_schema guard and
why MariaDB forces one
link/v5.md the sweep has to DIFF the fees or the frame never
comes -- stated as the general rule for the next
enrichment, since it is emit cadence and not shape
modules/uo/API.md §5.7a the cancel-shaped trigger and the Ageless-vs-
LikeNew ServUO fact; §5.7b every link comes from
config/clientPaths.js, and the two mistakes that made
every call-to-action a dead link
Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>.
Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
18 KiB
Markdown
280 lines
18 KiB
Markdown
# 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, `<servuo>`, 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.
|
||
|
||
**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()`
|
||
compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made
|
||
of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A
|
||
vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the
|
||
consumer that exists for exactly that event could fire only by coincidence: when somebody happened to
|
||
reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and
|
||
fixed there (ENGAGEMENT.md, decision 13).
|
||
|
||
The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An
|
||
integer division moves only when the shard's own answer to "is this vendor in danger" moves, while
|
||
`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a
|
||
fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape:
|
||
no field was added and `PROTOCOL_VERSION` is untouched.
|
||
|
||
**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change
|
||
detector, and a field added to the frame that is not added to the detector is a field that ships
|
||
correct and arrives never.
|
||
|
||
### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed
|
||
|
||
`account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which
|
||
`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.
|