docs(link): protocol 6 as built — a guarantee, and the kind that pays for it
`link/v6.md` is the spec of record. It is the first bump that adds a promise rather than data: a command carrying an `idempotencyKey` is executed at most once, and a repeat is answered with the original reply. Also in this PR: - `INTEGRATION.md` — §2 gains v6, the write plane gains a "retrying a command safely" section with the four rules a caller needs, §7 gains 425 and the paragraph on why it is not 409, and the event catalog gains `champ.boss.killed`. - `PLAN.md` — §5.5 records that a per-KILL damage table needs no override even though a per-HIT event does, which is the observation the new kind rests on; §7 documents the key on the inbound frames. - `EVENTS.md` — §A row 7 and two §G capability rows close, and the failure table's "an action succeeds but the ack is lost" loses its "does not exist today". - `EVENTS_PLAN.md` — Phase 11 splits into 11a and 11b, and `installer` leaves the phase: PLAN.md §7.4 made sure no protocol version is hardcoded there, so the bundle gate is version-agnostic and needed no change for 5 either. The live walk's defect is written up in v6.md §6.1 because the naive check confirms it: an active ChampionSpawn registers an unnamed region over its own spawn area, so the innermost region containing a champion boss is guaranteed to have no name — and region registration is deferred, so a lookup at spawn time answers "Britain" while one at the kill does not. CRLF preserved throughout; links and anchors checked by hand, this repo has no CI. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
|
||||
|
||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||||
|
||||
The current version is **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());
|
||||
|
||||
Reference in New Issue
Block a user