docs(link): protocol 4 — guild membership on the wire
Adds v4.md as the spec of record for `guild.roster` and `guild.leave`, and corrects the two older documents that Protocol 4 makes wrong. PROTOCOL_2.md §10.1 already described this design — hold a member-serial set, diff it each sweep, emit join/leave — and 2.0 then shipped only the half needing no new state, folding membership into the board signature as a serial *sum*. The section has read ever since as though the whole thing were built. It now says which half shipped, and carries the correction that doing it produced: a sum is not a safe stand-in for a set, because one member joining and another leaving between two sweeps offset each other and the guild reads as unchanged. INTEGRATION.md gains both kinds in the event catalogue, the `roster` key on GET /guilds, and the three things an integrator gets wrong otherwise — that `guild.leave`'s `who` is a bare serial rather than an actor object (the mobile has already left, so there is nothing to attribute), that `acct` is genuinely optional on a member, and that a guild with no `roster` key is not the same as one with an empty roster. v4.md documents what the phase found as well as what it built: the missing store migration and the user_version decision, why the roster lives in its own column rather than inside the guild.update snapshot, why a split roster is reassembled in memory rather than appended to the column, and why guild.leave gets no board projection at all. §6 records that the reassembly bug was invisible to every unit test — they all exercised single-frame rosters — and only the live rig caught it. TEAMS.md is amended where this phase disagreed with it: Phase 1 spans five repos, not four, because installer/backup.rs justifies skipping the sidecar database on reasoning the migration falsifies. The user_version decision is recorded there too, since the design of record did not contemplate a migration mechanism at all. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -283,11 +283,19 @@ Guilds expose only one in-game event (a member joining), so the roster is polled
|
||||
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's leader/alliance/name changed, its member count moved, or its first sight this connection. |
|
||||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
||||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
||||
| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** |
|
||||
| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. |
|
||||
|
||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute.
|
||||
|
||||
**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went.
|
||||
|
||||
`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.
|
||||
|
||||
**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.
|
||||
|
||||
```json
|
||||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
||||
@@ -296,8 +304,16 @@ The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}`
|
||||
"t":1752489280000}
|
||||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
||||
{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||||
"total":14,"seq":0,"more":false,
|
||||
"members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||||
{"serial":"0x77","name":"Bran","acct":"bran","player":true}],
|
||||
"t":1752489282000}
|
||||
{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000}
|
||||
```
|
||||
|
||||
`acct` is genuinely optional on a member — a character can have no account at all — so do not assume it is present.
|
||||
|
||||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
||||
|
||||
#### Town governors (Protocol 2.0)
|
||||
@@ -853,7 +869,17 @@ GET /guilds
|
||||
"t":1752489280000}, ... ] }
|
||||
```
|
||||
|
||||
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
||||
Every guild's latest snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Ordered by name. Survives a sidecar restart.
|
||||
|
||||
Each entry is a `guild.update` payload **plus, from Protocol 4, a `roster` key** holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint:
|
||||
|
||||
```
|
||||
→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[
|
||||
{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||||
{"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] }
|
||||
```
|
||||
|
||||
A guild that has had a `guild.update` but no roster yet has **no `roster` key at all** — deliberately distinct from `"roster": []`, which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty".
|
||||
|
||||
### Governor board (Protocol 2.0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user