docs(link): protocol 5 — the decay schedule, vendor fee state and account.login.result
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user