docs(link): protocol 5 — the decay schedule, vendor fee state and account.login.result #193
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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 |
|
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
|
||||||
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
||||||
|
|||||||
@@ -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 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.
|
- Every response carries an **`X-UOLink-Version: 5`** header.
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`.
|
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
|
||||||
- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||||
|
|
||||||
```json
|
```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.
|
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
|
so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once
|
||||||
it declares `4`.
|
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
|
**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
|
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
|
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.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
||||||
| `mob.logout` | `who` |
|
| `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
|
#### Economy & commerce
|
||||||
| kind | fields | notes |
|
| kind | fields | notes |
|
||||||
@@ -204,15 +220,36 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
|||||||
#### Housing / IDOC
|
#### Housing / IDOC
|
||||||
| kind | fields |
|
| 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.
|
`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
|
```json
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
"map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
"ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
|
||||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
"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)
|
#### Economy supply (periodic)
|
||||||
@@ -523,14 +560,46 @@ shop name or location actually change.
|
|||||||
|
|
||||||
| kind | fields | notes |
|
| 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. |
|
| `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
|
```json
|
||||||
{"kind":"vendor.listing","serial":"0x40001234",
|
{"kind":"vendor.listing","serial":"0x40001234",
|
||||||
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||||||
|
"ownerAcct":"darrow_acct",
|
||||||
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||||||
"region":"Britain","house":"Darrow's Villa"},
|
"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,
|
"count":2,"total":2,"truncated":false,
|
||||||
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
||||||
"price":25000,"name":null,"cliloc":1023721},
|
"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:
|
A typical character page:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" };
|
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" };
|
||||||
|
|
||||||
// 1. render the roster
|
// 1. render the roster
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||||
|
|||||||
25
link/PLAN.md
25
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 online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
| 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. |
|
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
| 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. |
|
| **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 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 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. |
|
| 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.**
|
> ⚠️ `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 |
|
| Signal | Hook | Freq | Notes |
|
||||||
|--------|------|:----:|-------|
|
|--------|------|:----:|-------|
|
||||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
| 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<Serial, DecayLevel>` 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.
|
Hold a `Dictionary<Serial, DecayLevel>` 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<Serial, DecayLevel>` 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.
|
- **`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`.
|
- 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`.
|
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
|
### 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.
|
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)
|
### Outbound (shard → sidecar)
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
261
link/v5.md
Normal file
261
link/v5.md
Normal file
@@ -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, `<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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -7,7 +7,10 @@ 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 +
|
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
|
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
|
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. **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
|
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),
|
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
|
**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 +27,10 @@ none.
|
|||||||
|
|
||||||
1. **The in-app channel is in scope.** It does not exist today and has to be built, not adapted.
|
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.
|
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
|
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
|
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.
|
what that deletes and §6/Phase 1 for the operator cutover it forces.
|
||||||
@@ -1359,8 +1365,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 |
|
| **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 |
|
| **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 |
|
| **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` |
|
| **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` · `modules/uo/README.md` | `module-uo/README.md` |
|
| **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 |
|
| **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 |
|
| **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability |
|
||||||
|
|
||||||
@@ -3000,47 +3006,194 @@ 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 |
|
| 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/` | **(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 emitter** |
|
| `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`); houses board carries the new fields |
|
| `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 |
|
| `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 table + example, `docs/link/PLAN.md` §5/§7 |
|
| `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
|
**Two things the emitter work must not get wrong**, both following from §0.3's finding about how
|
||||||
PowerShell stub) shows a real transition carrying the new fields; a v4 overlay paired with a v5 sidecar
|
ServUO actually decays:
|
||||||
is **refused by the installer**, not mis-parsed; CI publishes a bundle whose manifest pairs v5 with v5.
|
|
||||||
|
- `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
|
**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.
|
starts early and lands independently.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 11 — module-uo's triggers and the first real rule
|
#### Phase 10 as built (2026-08-31)
|
||||||
|
|
||||||
`module-uo` registers `uo.house.idoc_warning` (and siblings), emits from `shardIngest`, and ships the
|
**Design of record: [`../link/v5.md`](../link/v5.md).** All three enrichments shipped as scoped,
|
||||||
"greatly damaged" mapping. **The `Greatly` transition mapping needs no protocol change** and can ship
|
and the phase found nothing that changed its shape — which is itself worth noting, because the two
|
||||||
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
|
*additions* to this phase existed only because reading the emitters had already contradicted §8.6
|
||||||
until the v5 overlay is deployed, which the `required: false` declaration already permits.
|
twice.
|
||||||
|
|
||||||
**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is
|
**Two design decisions taken during the build, neither of them in the phase text:**
|
||||||
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.
|
|
||||||
|
|
||||||
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
|
- **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`
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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
|
`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
|
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
|
cancels the pending mail; a player vendor drawn down to its last day produces one `uo.vendor.expiring`
|
||||||
every registered post hook still fire exactly as they did.**
|
to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one
|
||||||
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
|
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 +3523,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
|
## 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,
|
Written as a catalogue, not a commitment — to check that the design in Parts 3–5 is general enough, and
|
||||||
and to show what an operator gets for the phases they pay for. **Nothing here is scheduled**; Phase 11
|
to show what an operator gets for the phases they pay for. **§8.6 is no longer that.** On 2026-08-31 the
|
||||||
ships exactly one rule.
|
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
|
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
|
engine lands · **⚠** the data exists but needs a mapper or a resolver · **❌** needs new plumbing
|
||||||
@@ -3464,16 +3619,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
|
### 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;
|
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.
|
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 |
|
| 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.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.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.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`, vendor fees | ⚠ | Your vendor is about to be dismissed for unpaid fees |
|
| `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_attempt` | `account.login.attempt` | ✅ (already a personal stream) | Someone tried to log into your game account, from where |
|
| `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.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.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 |
|
| `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail |
|
||||||
@@ -3494,7 +3654,25 @@ core stays ignorant of every word in it.
|
|||||||
| `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
|
| `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
|
||||||
| `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend |
|
| `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
|
- **`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
|
stored query matches this listing", which `audience: 'computed'` covers but a per-user query store
|
||||||
|
|||||||
Reference in New Issue
Block a user