docs(link): protocol 6 as built — a guarantee, and the kind that pays for it #219
@@ -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 **5**, shipped on 2026-09-01 in sidecar **v2.1.0** and overlay **v1.1.0** — resolve them as bundle **2026.09.01**, never as "latest of each". The pairing before it was protocol 4, sidecar **v2.0.0** + overlay **v1.0.0**.
|
||||
The current version is **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each".
|
||||
|
||||
- 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**:
|
||||
- Every response carries an **`X-UOLink-Version: 6`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
|
||||
- **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```json
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" }
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
|
||||
```
|
||||
|
||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||
@@ -97,6 +97,21 @@ both about ABSENCE rather than presence, because both are easy to read as an err
|
||||
- `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".
|
||||
|
||||
**v6 (Protocol 6)** is the first bump that is mostly about a **guarantee** rather than about data
|
||||
([`v6.md`](v6.md)). A command may now carry an **`idempotencyKey`**, and the shard promises to
|
||||
execute a key **at most once**: a repeat is answered with the original reply rather than re-run. That
|
||||
is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a
|
||||
command that never applied were the same event as seen from the caller. See §6's write plane for how
|
||||
to send one, and §7 for the one new status code it introduces.
|
||||
|
||||
It also adds **`champ.boss.killed`**, a champion's defeat with the damage table only the shard ever
|
||||
sees. Previously this was inferable from `champ.update` losing its `bossUp` alongside a nearby
|
||||
`mob.killed` — a signal that also fires when a GM resets a spawn and that says nothing about who did
|
||||
the work.
|
||||
|
||||
Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key
|
||||
behaves exactly as it did under v5.
|
||||
|
||||
**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
|
||||
@@ -295,6 +310,7 @@ Champion spawns have no in-game event either, so they're polled (`ChampSweepSeco
|
||||
|------|--------|-------|
|
||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||||
| `champ.boss.killed` (Protocol 6) | `category`, `bossSerial`, `boss`, `bossType`, `map`, `x`,`y`,`z`, `region`, `killer`, `damagers` — plus `serial`, `type`, `level` naming the ALTAR when the kill could be attributed to one | The boss went down. A real event, not a polled diff — see below. |
|
||||
|
||||
`status` is one of:
|
||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||||
@@ -322,6 +338,43 @@ Category-specific fields on `champ.update`:
|
||||
|
||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||||
|
||||
##### `champ.boss.killed` (Protocol 6)
|
||||
|
||||
The one champion frame that is **not** polled. It fires on the death itself, so unlike everything
|
||||
above it is an event rather than a difference between two snapshots — which means a first sighting
|
||||
of it is the thing being reported rather than a baseline to compare against.
|
||||
|
||||
```json
|
||||
{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar",
|
||||
"bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain",
|
||||
"serial":"0x400150E8","type":"Abyss","level":0,
|
||||
"killer":{"serial":"0x2E0","name":"tester","player":true},
|
||||
"damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240},
|
||||
{"serial":"0x24C","name":"Darrow","player":true,"damage":120}],
|
||||
"t":1788551315000}
|
||||
```
|
||||
|
||||
- **`serial` means the ALTAR here**, matching `champ.update`, so the two join without a rule about
|
||||
which of two serials means what. It is **absent** — with `type` and `level` — when the boss could
|
||||
not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval.
|
||||
The kill is still reported; it simply arrives without its altar.
|
||||
- **`damagers` is every player who damaged it, highest first**, each the standard actor object plus
|
||||
a `damage` total. Totals are summed per player, so nobody appears twice. Entries are included
|
||||
whether or not the shard still considers them valid for **looting rights** — someone who fought
|
||||
two thirds of the fight and then died took part in it. Capped at 20.
|
||||
- **`region` is the nearest NAMED region** and is **absent** in open countryside. It is not the most
|
||||
specific region containing the boss: an active champion altar registers an unnamed region of its
|
||||
own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a
|
||||
name", never "the shard would not say".
|
||||
- `category` is `champion` or `sea`. There is no `mini` — a `MiniChamp` has no boss.
|
||||
|
||||
**This does not replace `champ.remove`.** A slain `sea` boss still produces one, because it also
|
||||
leaves the board. A `champion` altar stays on the board and goes to `cooldown` as usual.
|
||||
|
||||
**`damagers` names players and ranks them.** The sidecar serves it verbatim, as it serves
|
||||
everything; deciding who may see a damage table is the consuming site's job. The website's own
|
||||
answer is `staff` by default with the kill itself public — see [`v6.md`](v6.md) §4.
|
||||
|
||||
#### Guilds (Protocol 2.0)
|
||||
|
||||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
||||
@@ -878,6 +931,45 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web
|
||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
||||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
||||
|
||||
### Retrying a command safely — `idempotencyKey` (Protocol 6)
|
||||
|
||||
Any command in this section may carry an **`idempotencyKey`**, and the shard promises to execute a
|
||||
key **at most once**. A repeat is not re-run: it is answered with the **original reply**, restamped
|
||||
with the repeat's own correlation id and marked `"replayed": true`.
|
||||
|
||||
```json
|
||||
POST /admin/broadcast
|
||||
{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" }
|
||||
|
||||
→ 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 }
|
||||
→ 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true }
|
||||
```
|
||||
|
||||
Note the second reply's `t`: it is the **first** attempt's, because it is the stored answer rather
|
||||
than a fresh execution. The world write happened once.
|
||||
|
||||
This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement
|
||||
and a command that never applied are the same event as seen from here, and the only safe policy is
|
||||
to give up on the announcement rather than risk sending it twice.
|
||||
|
||||
Four rules for a caller:
|
||||
|
||||
- **A key belongs to your unit of work, not to the attempt.** Derive it from something stable — the
|
||||
website's event runner uses `sha256(runId|stepId)` — so every retry of one action carries the
|
||||
same key and a different action never collides with it. A fresh value per call satisfies the field
|
||||
and defeats the entire mechanism.
|
||||
- **A key is remembered for one hour**, bounded at 4096 keys per shard. Retry inside that window.
|
||||
- **A repeat that arrives while the original is still in flight** is answered **425 Too Early**
|
||||
(`{"kind":"bridge.busy"}`). Nothing ran; come back. It is transient by construction.
|
||||
- **A replayed reply is an ordinary 200.** Treat it exactly as you would have treated the answer you
|
||||
lost; `replayed` is for your log.
|
||||
|
||||
Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a
|
||||
human just pressed a button for and can see the result of.
|
||||
|
||||
The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the
|
||||
second removal of an entry is a no-op the shard is already happy to perform.
|
||||
|
||||
### Help-page (support) queue
|
||||
|
||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
||||
@@ -1075,6 +1167,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||||
| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry |
|
||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||||
| 500 | Internal error (e.g. database) |
|
||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
||||
@@ -1082,6 +1175,11 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
|
||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
||||
|
||||
`425` vs `409`: both are conflicts of a sort and they want **opposite** responses. A `409` is a
|
||||
deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will
|
||||
never help. A `425` is a retry that will succeed on its own. They are deliberately different codes
|
||||
so a retry loop cannot quietly swallow a mismatched deployment.
|
||||
|
||||
---
|
||||
|
||||
## 8. Putting it together
|
||||
@@ -1089,7 +1187,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": "5" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
24
link/PLAN.md
24
link/PLAN.md
@@ -178,10 +178,16 @@ Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanL
|
||||
| Player death | `PlayerDeath` | low | |
|
||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. **Protocol 6 uses it for `champ.boss.killed`**: the handler's first act is to decide this is not a champion, by type, which is one `is` check per death. |
|
||||
| **Damage table at a kill** | `Mobile.DamageEntries`, read inside `CreatureDeath` | — | Not a stream: the accumulated per-attacker totals, readable **only at the death**, since ServUO discards them with the creature. This is what makes a boss kill attributable without a per-hit event. |
|
||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
||||
|
||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
||||
>
|
||||
> But per-*kill* attribution needs no override at all, and Protocol 6 is where that was noticed:
|
||||
> `Mobile.DamageEntries` already holds a running total per attacker, and a `CreatureDeath` handler
|
||||
> reads it before the creature is disposed of. A whole participation signal, for free, at the one
|
||||
> moment it is worth having.
|
||||
|
||||
### 5.6 Progression & activity
|
||||
|
||||
@@ -295,6 +301,22 @@ Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||||
|
||||
Every inbound handler marshals to the Core thread before touching world state.
|
||||
|
||||
### Any inbound command may carry an `idempotencyKey` (Protocol 6)
|
||||
|
||||
```jsonc
|
||||
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"…","idempotencyKey":"5f2c…"}
|
||||
```
|
||||
|
||||
The shard executes a key **at most once** and answers a repeat with the original reply, restamped
|
||||
with the repeat's correlation id and marked `"replayed": true`. That is what makes a world-writing
|
||||
command safe to retry after a lost acknowledgement, and it is a precondition for every world verb
|
||||
the event system adds later — see [`v6.md`](v6.md) §2.1.
|
||||
|
||||
The gate sits in `BridgeBoot`'s inbound dispatch rather than in each handler, so it covers every
|
||||
kind including ones a later protocol adds. A command with no key behaves exactly as it did before.
|
||||
One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still
|
||||
in flight, nothing ran, come back.
|
||||
|
||||
### `server.hello` is per-connection, not per-boot
|
||||
|
||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
||||
|
||||
331
link/v6.md
Normal file
331
link/v6.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Protocol 6 — A guarantee, and the kind that pays for it
|
||||
|
||||
**Status:** In review on `edge`. Part **a** of two: see §7 for what protocol 6 gains in 11b before it cuts over.
|
||||
**Date:** 2026-09-04
|
||||
**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), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||
**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this bump is not like the other five
|
||||
|
||||
Every protocol version so far added **data**: a field, a frame, a board. This one adds a **promise**,
|
||||
and the data it also carries is along for the ride because the ride is expensive.
|
||||
|
||||
The promise is one sentence:
|
||||
|
||||
> **A command carrying an `idempotencyKey` is executed at most once. A repeat is answered with the
|
||||
> original reply.**
|
||||
|
||||
Nothing on the wire could say that before, and [`EVENTS.md`](../website/EVENTS.md) §A row 7 records
|
||||
what that cost: *"a command whose ack is lost is indistinguishable from one that never applied."*
|
||||
The website's event runner retries a step that did not come back — that is what a runner is for —
|
||||
so every world-writing verb had to be declared un-retryable, and Phase 9 shipped `uo.broadcast`
|
||||
answering `retry: false` to *everything*, including a 503 from a shard that was merely restarting.
|
||||
The reasoning was sound and the trade was real: a lost announcement is cheaper than a doubled one.
|
||||
|
||||
It is not a trade that survives contact with the rest of the workstream. An event that spawns
|
||||
creatures, grants an item, or holds a lease on a live config value cannot be built on a wire where
|
||||
"did that apply?" has no answer. §G's capability table says so in as many words: *"Required before
|
||||
any world write ships."*
|
||||
|
||||
**So the guarantee had to come before the verbs, and this is the version that has nothing else to
|
||||
do.** Protocol 12's world verbs are the payload; protocol 6 is the floor they stand on.
|
||||
|
||||
### 1.1 Why `champ.boss.killed` rides along
|
||||
|
||||
The same argument v5 §1 made: a bump costs a sidecar release, a republished bundle and an operator
|
||||
update on every shard, so a field left out costs a *second* bump rather than a follow-up commit.
|
||||
|
||||
`champ.boss.killed` is the one piece of new data the events workstream is known to need and can
|
||||
state now. §G's capability table had a boss defeat as 🔗 — *inferable* from `champ.update` going
|
||||
`bossUp` true then false, correlated against a nearby `mob.killed` — and called that
|
||||
*"workable and slightly fragile"*. It is more fragile than that: `bossUp` also drops when a GM
|
||||
resets a spawn, when a boss despawns, and after a sidecar reconnect clears the sweep's diff cache.
|
||||
And the inference is silent about the thing an event most wants to know, which is **who fought it**.
|
||||
|
||||
A phase condition is written over a trigger firing, so a kind nothing declares is a kind no event
|
||||
can wait on. *"Advance when the boss falls"* is the canonical UO event phase boundary, and it did
|
||||
not exist.
|
||||
|
||||
---
|
||||
|
||||
## 2. The shard side
|
||||
|
||||
### 2.1 The idempotency key
|
||||
|
||||
**One new optional field on every inbound command**, and no new command kinds:
|
||||
|
||||
```json
|
||||
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"...",
|
||||
"idempotencyKey":"5f2c…40 hex"}
|
||||
```
|
||||
|
||||
The gate is in `BridgeBoot.OnInboundLine`, before the handler is dispatched, so it covers **every**
|
||||
inbound kind including the ones a later protocol adds — which is the half that is easy to forget. A
|
||||
command with no key behaves exactly as it did before protocol 6, which is what leaves the admin
|
||||
screens unchanged: a human pressing a button can see whether the thing happened, and sends none.
|
||||
|
||||
The store is `BridgeIdempotency`. Four rules, each of which is a decision rather than an
|
||||
implementation detail.
|
||||
|
||||
**1. Reserve on receipt, not on completion.** The key is recorded *before* the handler runs. A
|
||||
handler that finishes inside its own inbound call can never see a repeat — the Core thread processes
|
||||
one line at a time — but a handler that defers (a lease that arms a timer, a spawn that waits for a
|
||||
save) completes long after `OnInboundLine` has returned, and that is precisely the window a lost
|
||||
acknowledgement opens. Reserving late would leave it uncovered.
|
||||
|
||||
A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, and the caller is
|
||||
told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work
|
||||
is happening.
|
||||
|
||||
**2. A key that has begun is never released.** Not even when the handler throws. Releasing it would
|
||||
let a retry re-run a command that may have applied half of itself, which is the exact failure this
|
||||
file exists to prevent. A handler that throws stores a `bridge.error` reply instead, so the retry
|
||||
gets a definite answer and the step fails once rather than looping.
|
||||
|
||||
**3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh
|
||||
per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the
|
||||
original id verbatim would leave the call hanging until the reply timeout — the very failure being
|
||||
answered. The stored reply is re-emitted with its correlation field rewritten and `"replayed": true`
|
||||
appended; everything else is byte-for-byte the original, including its original `t`.
|
||||
|
||||
`reqId`, `code` and `id` are all recognised, in the order `rpc.rs` tries them. A repeat that
|
||||
correlates on a *different* field than the original did is refused with a `bridge.error` rather than
|
||||
answered under an id nobody is waiting on.
|
||||
|
||||
**4. The bound is loud.** TTL one hour, cap 4096 keys, drop-oldest. The hour is not a tuned number:
|
||||
core's step lease is fifteen minutes and its retry backoff is bounded well inside that, so expiry
|
||||
should never be the thing that ends a key's life. The cap is the guarantee's **one hole** — an
|
||||
evicted key's repeat *would* be applied a second time — so an eviction that drops a key still inside
|
||||
its TTL prints a console warning naming the count. If the promise is ever actually breached, an
|
||||
operator reads it here rather than discovering a doubled spawn in the world.
|
||||
|
||||
`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated=)`.
|
||||
|
||||
#### 2.1.1 How the reply is captured
|
||||
|
||||
The handlers emit their answers through `BridgeLink.Emit` like everything else, so there is nothing
|
||||
to return. While a keyed handler runs, `Emit` offers each line to the store, and the store keeps
|
||||
**only the line the sidecar would correlate with this command** — the one whose `reqId`/`code`/`id`
|
||||
equals the command's.
|
||||
|
||||
That filter is the point. An `admin.audit` frame emitted alongside the reply is a fact about the
|
||||
world and must **not** be replayed to a retry; the reply is an answer to a caller and must be. The
|
||||
capture happens before `Emit`'s `_running` check, too, because a reply the link was too dead to
|
||||
deliver is exactly the one a retry will come back for.
|
||||
|
||||
A keyed command that produces no correlated reply at all is treated as a defect rather than as a
|
||||
state to model: it is logged by kind and key, and a definite `bridge.error` is stored so the retry
|
||||
terminates instead of timing out forever.
|
||||
|
||||
### 2.2 `champ.boss.killed`
|
||||
|
||||
A new kind, emitted from `EventSink.CreatureDeath` in `BridgeChamps`.
|
||||
|
||||
```json
|
||||
{"t":1788551315,"kind":"champ.boss.killed","category":"champion",
|
||||
"bossSerial":"0xD8D","boss":"Semidar","bossType":"Semidar",
|
||||
"map":"Felucca","x":1496,"y":1628,"z":-5,
|
||||
"serial":"0x400150E8","type":"Abyss","level":0,
|
||||
"region":"Britain",
|
||||
"killer":{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true},
|
||||
"damagers":[{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true,"damage":100240},
|
||||
{"serial":"0x24C","name":"Darrow","acct":"whitlocktech","webId":"14","player":true,"damage":120}]}
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `category` | `champion` or `sea`. There is no `mini`: a `MiniChamp` has no boss |
|
||||
| `bossSerial`, `boss`, `bossType` | The creature. `boss` prefers its display name and falls back to its type |
|
||||
| `serial`, `type`, `level` | **The ALTAR**, present only when the kill could be attributed to one. `serial` means the spawn here, matching `champ.update`, so a consumer joins the two without a rule about which of two serials means what |
|
||||
| `map`, `x`, `y`, `z`, `region` | Where it fell. `region` is the nearest **named** region and is absent in open countryside — see §6.1 |
|
||||
| `killer` | The standard actor object, and the last blow only |
|
||||
| `damagers` | Every player who damaged it, **highest first**, each an actor object plus `damage` |
|
||||
|
||||
**Detection is by TYPE, not by the sweep.** `BaseChampion` — which `BaseSeaChampion` derives from —
|
||||
so one check covers both families, and a boss that popped and died inside one sweep interval is
|
||||
still reported. The sweep's `bossSerial → spawnSerial` map supplies only the altar *context*; a kill
|
||||
it cannot attribute arrives without `serial`, `type` and `level` rather than not arriving.
|
||||
|
||||
**The damage table exists here and nowhere else.** ServUO discards a creature's damage entries with
|
||||
the creature, so the shard is the only party that ever sees them. Totals are summed per damager
|
||||
rather than trusted to be one entry each: ServUO folds repeat damage into an existing entry, but an
|
||||
entry that expired and was re-created leaves two, and a table listing the same player twice reads as
|
||||
two participants. Entries are reported **whether or not ServUO considers them expired** — expiry
|
||||
governs looting rights, and someone who fought the first two thirds of a champion fight and then
|
||||
died took part in it regardless of what they are owed from the corpse.
|
||||
|
||||
Capped at 20 entries so the frame stays one line on the wire.
|
||||
|
||||
---
|
||||
|
||||
## 3. The sidecar side
|
||||
|
||||
`PROTOCOL_VERSION: u32 = 5` → `6`, and **one behaviour**: `bridge.busy` maps to HTTP **425 Too
|
||||
Early** in all three responders.
|
||||
|
||||
Everything else is free. The key rides in the command body, which every write endpoint already
|
||||
passes through verbatim; `champ.boss.killed` lands in `events` and on the feed through the generic
|
||||
forward path with no arm of its own. There is **no store migration** — nothing gains a column.
|
||||
|
||||
That is the dumb-forwarder property doing its job again (v3 §3), and it is worth naming what it
|
||||
means here specifically: **the sidecar makes no idempotency promise of its own.** It does not dedupe,
|
||||
does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which
|
||||
is the only place it can be — the shard is where the world write happens.
|
||||
|
||||
### 3.1 Why 425 and not 409
|
||||
|
||||
409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a
|
||||
client: a version mismatch is a deployment fault nobody should retry, and a busy shard is a retry
|
||||
that will succeed on its own. Sharing a status would make the difference readable only by inspecting
|
||||
the body, which is how a retry loop ends up hiding a mismatched deployment.
|
||||
|
||||
425 is what that status is for — a server unwilling to risk processing a request that might be a
|
||||
replay — and it is unambiguous here because nothing else on this surface uses it.
|
||||
|
||||
A **replayed** reply, by contrast, is an ordinary **200**. The caller must be able to treat it
|
||||
exactly as it would have treated the answer it lost; `replayed: true` is for the log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visibility
|
||||
|
||||
One classification, made on the website in `module-uo`'s `shardVisibility.js`, never in the sidecar.
|
||||
|
||||
| Field / kind | Audience | Why |
|
||||
|---|---|---|
|
||||
| `champ.boss.killed` | **`anonymous`**, on the existing `champs` feature | A champion falling is announced in-world and is the content the public board is *for*. Mapping it is required, not optional: rule 2 fails an unmapped kind closed to admin-only |
|
||||
| `champ.boss.killed` → `damagers` | **`staff`**, configurable | Nested, so one rule covers the whole table |
|
||||
| `champ.boss.killed` → `killer` | **not listed** | One actor, whose blow everyone present saw, and the same disclosure `mob.killed` has published on the public activity feed since before this framework existed |
|
||||
| `damagers[].acct` / `.webId` | **admin, locked** | Rule 1, automatically, by suffix — inside the array, exactly as it already works inside a guild roster |
|
||||
|
||||
**The split is the whole point.** A shard announces that its champion fell without publishing a
|
||||
ranked roll of who was strong enough to fell it. The kill is an event in the world; the damage table
|
||||
is a performance record of named players that nobody consented to publish. A shard that wants a
|
||||
public "who slew the champion" board lowers **one** field rule.
|
||||
|
||||
**The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount`
|
||||
and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into
|
||||
mail an operator may address to every subscriber, so a name reaching the trigger's data would undo
|
||||
the field rule one layer up — a distinct hole from the SSE path the rule guards, reachable without
|
||||
touching the visibility config at all.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-repo obligations
|
||||
|
||||
| Repo | Change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** |
|
||||
| `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders |
|
||||
| `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` |
|
||||
| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` |
|
||||
| `installer` | **nothing.** See below |
|
||||
| `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
|
||||
|
||||
**`installer` has no work, and `EVENTS_PLAN.md` Phase 11 was wrong to list it.** The plan put it in
|
||||
the phase *"because of the pairing"*, but `PLAN.md` §7.4 made that unnecessary on purpose:
|
||||
**no protocol version is hardcoded anywhere in the installer.** `bundle.rs` reads the number out of
|
||||
the bundle document and `bundle.yml`'s Gate 1 reads both halves out of the released artefacts and
|
||||
refuses a mismatch. The check is version-agnostic, so it needs no change for 6 exactly as it needed
|
||||
none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is
|
||||
released or bundled until the events cutover in any case.
|
||||
|
||||
**The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and
|
||||
`module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree*
|
||||
rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
Unit tests: 571 in `module-uo/server` (571 pass / 0 fail, 9 new), 43 in the sidecar (4 new), 42 in
|
||||
`module-uo/client`; the C# compiles against the real ServUO 57.4 reference assemblies. `cargo fmt`,
|
||||
`cargo clippy -D warnings` and `check:imports` clean.
|
||||
|
||||
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/BridgeProtocol6Probe.cs`.
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| a repeat is not re-executed | Three `POST /admin/broadcast`, two under key `K1` and one under `K2`. The shard's own audit trail holds **two** `admin.audit` rows, not three |
|
||||
| a replay is the ORIGINAL reply | Attempt 2 answered `{"kind":"admin.ok","replayed":true,"reqId":"r-2","t":1788550182074}` — the first attempt's `t`, so it is stored rather than re-run |
|
||||
| a replay is stamped with the REPEAT's id | The same reply carries `reqId: r-2`, not the `r-1` the first attempt used. Without this the retry hangs until the reply timeout |
|
||||
| the `id`-correlated plane works too | Two `POST /towncrier` under one key → `{"kind":"towncrier.ok","id":"evt-CRIERKEY","replayed":true}` on the second, correlated on `id` rather than `reqId` |
|
||||
| a different key still executes | Attempt 3 answered a fresh `r-3` with a new `t`, and produced the second audit row |
|
||||
| the shard says what it did | `[Bridge] idempotency: replaying the original reply for key K1-… (admin.broadcast)` |
|
||||
| `champ.boss.killed` fires for a real champion | A real `ChampionSpawn` driven through its own `SpawnChampion()`, damaged from two seeded players and killed: `boss: Semidar`, `category: champion` |
|
||||
| the altar is attributed | `serial: 0x400150E8`, `type: Abyss`, `level: 0` — from the sweep's map, so the kill names the altar and not only the creature |
|
||||
| the damage table ranks and SUMS | `[("tester", 100240), ("Darrow", 120)]` — descending, and `tester`'s 240 registered damage plus the 100000 killing blow folded into one entry rather than two |
|
||||
| the region resolves | `region: "Britain"` — see §6.1, this is the one that took four rig cycles |
|
||||
| the website's half agrees, on the real bytes | The captured frame fed through `module-uo`: trigger `uo.champ.boss_killed` with `location: "Felucca 1496, 1628 (Britain)"`, `damagerCount: 2`; projected `damagers` **absent** for `anonymous` and `player`, present for `staff`, and `acct` stripped from every actor at every rung below admin |
|
||||
| the sidecar needed no change beyond the constant and one status | `GET /health` → `"protocol":6`; the key and the new kind both arrived through the generic paths |
|
||||
|
||||
**One thing the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat can
|
||||
never arrive while the original is still running, because the Core thread processes one inbound line
|
||||
at a time — so the state is unreachable on a live shard until a handler defers. It is implemented,
|
||||
`BridgeIdempotency.Hold`/`Complete` are the door a deferring handler uses, and the sidecar's 425
|
||||
mapping is unit-tested on all three responders. **Phase 11b's leases are the first thing that can
|
||||
actually produce it**, and proving it belongs in that walk rather than being claimed here.
|
||||
|
||||
### 6.1 The rig trap: the innermost region has no name
|
||||
|
||||
Recorded because it is the sort of defect that ships, and because the naive check *confirms* it.
|
||||
|
||||
The first emitter read `boss.Region` and a champion killed in the middle of Britain produced a frame
|
||||
with **no region at all**. Three things were true at once:
|
||||
|
||||
- An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its spawn area, constructed with
|
||||
a **null name** and with the town region as its `Parent`. So the most specific region containing a
|
||||
champion boss is, by construction, the one region on the map guaranteed to have no name.
|
||||
- `Mobile.Region` never returns null: with no cached region it falls back to the map's **unnamed
|
||||
default region**. So the failure looks identical to "outdoors, nowhere in particular".
|
||||
- `ChampionSpawn.SpawnChampion` places its boss at `Z - 15`, which was a plausible-looking second
|
||||
explanation and a red herring. It cost a rig cycle.
|
||||
|
||||
And the reason it survived a first look: **region registration is deferred**, so a lookup taken
|
||||
immediately after the altar is placed still answers `"Britain"` and one taken at the kill twenty
|
||||
seconds later does not. The probe printed `region "Britain"` at spawn on every run, including the
|
||||
ones whose frame carried nothing. A read at spawn time would have confirmed the bug into the design.
|
||||
|
||||
The fix is general rather than a special case for champions: walk outward to the nearest **named**
|
||||
ancestor. A house region, a dungeon sub-region and a guarded-zone overlay are all anonymous children
|
||||
of somewhere a player would name. The map's own default region terminates the walk with its
|
||||
parentless empty name, so open countryside answers null — and the field is then **omitted**, because
|
||||
a consumer reading `region: ""` cannot tell "nowhere in particular" from "the shard would not say".
|
||||
|
||||
### 6.2 Two smaller ones
|
||||
|
||||
- **`deploy.ps1` overwrites `Bridge.cfg`,** so every deploy strips the scaffolding flags —
|
||||
`Protocol6ProbeOnStart` and, here, `AdminWriteEnabled` as well. The probe then silently does
|
||||
nothing on the next boot. The scaffolding README warns about this and it still cost a cycle.
|
||||
- **A running ServUO holds `Scripts.dll`,** so an offline `msbuild` fails with `MSB3027` rather than
|
||||
a compile error. Stop the shard, build, restart — and read the boot log's `0 Error(s)` to confirm
|
||||
the new code is live rather than trusting a clean boot, which is what
|
||||
`servuo-plugin-build-and-run` already records.
|
||||
|
||||
---
|
||||
|
||||
## 7. What 11b adds to protocol 6
|
||||
|
||||
Phase 11 is split. **This document covers 11a**; 11b adds lease deadlines and the run-scoped
|
||||
participation ledger to the *same* protocol version, amended in place rather than bumped to 7 —
|
||||
6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule is that
|
||||
a protocol owes a bump once it has shipped and is amended in place before that.
|
||||
|
||||
Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side
|
||||
of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle
|
||||
CI never sees either half until the cutover, by which time 6 means one thing. It would not be
|
||||
tolerable on `main`, and this paragraph exists so nobody discovers that the hard way.
|
||||
|
||||
What 11b will add here:
|
||||
|
||||
- `lease.apply` / `lease.release` / `lease.list`, with a deadline the shard honours **without being
|
||||
asked again** and a compare-and-set restore that reports `drifted` rather than overwriting a GM's
|
||||
deliberate change.
|
||||
- `participation.open` / `participation.snapshot` / `participation.close`, keyed by character serial
|
||||
to match `module-uo`'s existing `memberKey`, and **persisted in the world save** — the Bridge's
|
||||
first persisted state, so a tally survives a restart mid-event.
|
||||
- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`.
|
||||
@@ -272,7 +272,7 @@ What is genuinely not there, stated as what the codebase says rather than as ass
|
||||
| 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. |
|
||||
| 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. |
|
||||
| 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. |
|
||||
| 7 | **The command path is not durable and has no idempotency.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. |
|
||||
| 7 | ~~**The command path is not durable and has no idempotency.**~~ **Closed by protocol 6** (Phase 11a). `rpc.rs` is unchanged and still an in-memory pending map with a flat timeout — the replay was never the sidecar's to do. A command may now carry an `idempotencyKey`, and the SHARD executes a key at most once, answering a repeat with the original reply restamped for the retry's correlation id. Which is the only place the promise can live: the shard is where the world write happens. See [`../link/v6.md`](../link/v6.md) §2.1. |
|
||||
| 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. |
|
||||
| 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. |
|
||||
| 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. |
|
||||
@@ -1094,7 +1094,7 @@ a capability exists.
|
||||
| Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. |
|
||||
| Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. |
|
||||
| Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. |
|
||||
| Detect a boss defeated | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. |
|
||||
| Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. |
|
||||
| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. |
|
||||
| Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. |
|
||||
| Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. |
|
||||
@@ -1106,7 +1106,7 @@ a capability exists.
|
||||
| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. |
|
||||
| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. |
|
||||
| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
|
||||
| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** |
|
||||
| Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. |
|
||||
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
|
||||
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
|
||||
|
||||
@@ -1434,7 +1434,7 @@ controller stamps it from the session.
|
||||
| **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. |
|
||||
| **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. |
|
||||
| **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** |
|
||||
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* |
|
||||
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe*, and as of protocol 6 (Phase 11a) it exists: proved on the rig with three broadcasts under two keys producing **two** rows in the shard's own audit trail. It has one bound worth knowing — the plugin remembers a key for an hour, capped at 4096, and says so loudly if the cap ever evicts a live one. |
|
||||
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
||||
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
||||
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
||||
@@ -1768,7 +1768,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
|
||||
| **P8** ✓ | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||||
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
||||
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |
|
||||
| **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` |
|
||||
| **P11a** | *N1 answered.* Protocol 6: the idempotency key, and `champ.boss.killed` | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` |
|
||||
| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` |
|
||||
|
||||
@@ -1025,7 +1025,7 @@ site, cued to a GM, completed, recorded.
|
||||
**Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
|
||||
`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
|
||||
not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
|
||||
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
|
||||
wire until P11a. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
|
||||
declaration says why.
|
||||
|
||||
> **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option
|
||||
@@ -1246,25 +1246,117 @@ push; the ceiling on `run.failed` proved to exclude a moderator.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)
|
||||
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `docs`)
|
||||
|
||||
**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
|
||||
**§N1 answered 2026-09-01 — no longer gated.** A protocol bump, and the shape TEAMS Phase 1 already walked.
|
||||
|
||||
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a
|
||||
repeat with the original result; a lease deadline timer that restores baseline **without being
|
||||
asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind.
|
||||
**Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos,
|
||||
each with a different thing to prove on a live ServUO, is one review and one walk too many. They land
|
||||
as **one protocol version**: 11a bumps to 6, and 11b amends 6 **in place** on `edge` — the same rule
|
||||
the org lead set for `MODULE_API_VERSION` on 2026-09-03, and it applies because 6 will not reach
|
||||
`main` until the events cutover. The bundle CI therefore never sees two numbers.
|
||||
|
||||
**`installer` is NOT in this phase, and the line above that said so was wrong.** `PLAN.md` §7.4 made
|
||||
sure of it deliberately: no protocol version is hardcoded anywhere in the installer. `bundle.rs`
|
||||
reads the number out of the bundle document, and `bundle.yml`'s Gate 1 reads both halves out of the
|
||||
released artefacts and refuses a mismatch — version-agnostic, so it needed no change for 5 either.
|
||||
And `link`/`servuo-plugins` are on `edge`, so nothing is released or bundled until the cutover.
|
||||
|
||||
#### Phase 11a — the idempotency key, and `champ.boss.killed`
|
||||
|
||||
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers
|
||||
a repeat with the original result; `champ.boss.killed` as a first-class kind.
|
||||
- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
|
||||
and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
|
||||
compose.
|
||||
- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served.
|
||||
- **`installer`:** joins the phase because of the pairing above.
|
||||
- **Sidecar:** `PROTOCOL_VERSION` bumped; the key carried; the new kind stored and served.
|
||||
- **`module-uo`:** the key sent on every event-driven write, `uo.broadcast` made retryable, and the
|
||||
new kind mapped for visibility and declared as a trigger.
|
||||
- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
|
||||
|
||||
**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both
|
||||
are safety properties, not features.
|
||||
**Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the
|
||||
precondition every world verb in Phase 12 is waiting on.
|
||||
**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
|
||||
code is live; a deliberate duplicate command; a lease whose website is killed before it expires,
|
||||
proving baseline returns anyway.
|
||||
code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than
|
||||
against the reply.
|
||||
|
||||
> **Built.** `PROTOCOL_VERSION` and `overlay.toml` both **6**, in the same pair of PRs. The whole
|
||||
> sidecar change is one constant and one status mapping; the whole guarantee lives on the shard,
|
||||
> which is the only place it can, because the shard is where the world write happens.
|
||||
>
|
||||
> **Seven decisions (org lead, 2026-09-04), all as recommended.** The 11a/11b split; participation
|
||||
> keyed by character serial; the ledger persisted in the world save; leases as machinery plus one
|
||||
> proven key; reserve-on-receipt with `bridge.busy` for an in-flight repeat; a TTL-plus-cap with a
|
||||
> loud eviction; and top damagers at a `staff` ceiling. The last three are 11a's.
|
||||
>
|
||||
> **What the phase bought immediately:** `uo.broadcast` stopped being un-retryable. Phase 9 shipped it
|
||||
> answering `retry: false` to *everything* including a 503 from a restarting shard, with a comment
|
||||
> naming the line that would change. This is that line. It now defers to `sidecarFailure` — the same
|
||||
> helper its two siblings already used — so the hand-rolled variant that forced every outcome
|
||||
> terminal is gone rather than merely re-tuned.
|
||||
>
|
||||
> **One verb was less idempotent than its own `id` made it look.** Both keyed verbs post under a
|
||||
> run-scoped id and a repeat REPLACES, which is why Phase 9 called them safe to retry. But
|
||||
> `news.add` with `announce: true` makes the criers proclaim the article's title on every post, so a
|
||||
> retry replaced the article silently and proclaimed it **again**. The key stops the second
|
||||
> proclamation. Read in the plugin, not inferred: `BridgeNews` removes the old entry, inserts the
|
||||
> new one, and then announces.
|
||||
>
|
||||
> **The live walk found one defect, and the naive check confirms it.** A champion killed in the
|
||||
> middle of Britain emitted a frame with **no region**. An active `ChampionSpawn` registers a
|
||||
> `ChampionSpawnRegion` over its spawn area with a **null name** and the town region as its parent,
|
||||
> so the most specific region containing a champion boss is the one region on the map guaranteed to
|
||||
> be nameless — and `Mobile.Region` hides that by falling back to the map's unnamed default rather
|
||||
> than to null. Region registration is deferred, too, so a lookup at spawn time answers `"Britain"`
|
||||
> and one at the kill does not: the probe printed `"Britain"` on every run, including the ones whose
|
||||
> frame carried nothing. Fixed by walking outward to the nearest NAMED ancestor, which is the general
|
||||
> answer — house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of
|
||||
> somewhere a player would name.
|
||||
>
|
||||
> **Two findings that are rules rather than facts about events:**
|
||||
>
|
||||
> - **A trigger is not a stream, and in this module they are disjoint sets.** Push delivery is keyed
|
||||
> on the subscription id, which core's catalog grants only to registered STREAMS. `module-uo`'s
|
||||
> stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids (`uo.champ.started`, …) share
|
||||
> no member, so **no** engagement rule in this module can push — the tickle resolves to zero
|
||||
> endpoints while the send log records success. That is the Phase 10 defect, pre-existing here in
|
||||
> twenty rules. This phase does not fix them; it declines to add a twenty-first, so its rule ships
|
||||
> `['email','inapp']` with a comment saying why. **Flagged for the org lead as its own change.**
|
||||
> - **A new rule needs a new seed GROUP, never an appended one.** `triggers-v1` is stamped once under
|
||||
> a settings guard, so a twenty-seventh entry would reach fresh installs and nothing else.
|
||||
> `champ-boss-killed-v1` is its own group — the same remedy core applied in Phase 10, and the third
|
||||
> application of Engagement Phase 11's seed-key finding.
|
||||
>
|
||||
> **What the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat cannot
|
||||
> arrive while the original runs — the Core thread takes one inbound line at a time — so the state is
|
||||
> unreachable on a live shard until a handler defers. Implemented, with `Hold`/`Complete` as the door,
|
||||
> and unit-tested at the sidecar's mapping on all three responders. **11b's leases are the first
|
||||
> thing that can produce it**, and proving it belongs in that walk.
|
||||
>
|
||||
> See [`../link/v6.md`](../link/v6.md) for the spec of record.
|
||||
|
||||
#### Phase 11b — lease deadlines and the participation ledger
|
||||
|
||||
- **Plugin:** a lease deadline timer that restores baseline **without being asked**, with
|
||||
compare-and-set restore reporting `drifted`; a run-scoped participation ledger.
|
||||
- **Leases land as machinery plus ONE proven key** (org lead): the generic registry, the deadline
|
||||
timer, CAS restore and `lease.list` reconcile, proved end to end against one verified live-read
|
||||
`Config.Get` key. The curated allowlist and its boot-time self-check stay in Phase 12, where the
|
||||
rest of the lease surface is.
|
||||
- **The participation ledger counts presence in a declared area plus kill credit inside it, keyed by
|
||||
character serial** — matching `module-uo`'s existing Teams `memberKey`, so one module speaks one
|
||||
member vocabulary. The plugin computes the score; core stores an opaque number it never interprets.
|
||||
- **The ledger is persisted in the world save**, which makes it the Bridge's first persisted state
|
||||
ever. A run spans hours and a restart mid-event is realistic; an in-memory tally would regress the
|
||||
score after one, and the only ways to paper over that are a high-water rule in core (which must
|
||||
stay game-agnostic) or a per-run offset in the module.
|
||||
- **Protocol 6 is amended in place**, not bumped to 7. See the split note above.
|
||||
|
||||
**Ships:** the game side can put the world back on its own, and can say who took part.
|
||||
**Verify:** a lease whose website is killed before it expires, proving baseline returns anyway; a
|
||||
deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a tally that survives
|
||||
a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes
|
||||
reachable.
|
||||
|
||||
---
|
||||
|
||||
@@ -1396,7 +1488,7 @@ flowchart TD
|
||||
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
|
||||
P5 --> P10["P10 · integrations"]
|
||||
P9 --> P10
|
||||
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"]
|
||||
P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"]
|
||||
P8 --> P11
|
||||
P11 --> P12["P12 · UO world verbs"]
|
||||
P7 --> P13["P13 · authoring UI"]
|
||||
|
||||
Reference in New Issue
Block a user