Amends Protocol 4 in place rather than bumping it: the protocol has not reached `main`, and a bump is owed only once a protocol has been released. The roster shipped as the standard actor object, which carries no rank. Teams phase 2 found the consequence -- the website could learn leadership only from the board's single `leader` field, so it could name exactly one leader while a UO guild routinely has several, and TEAMS.md §2.5 treats multiple leaders as the normal case. Roster members now carry `rank` (0-4, 4 being Leader) plus `rankCliloc`, or `rankName` where a shard's custom rank definitions use literal names. Rank is on roster members only -- it is a property of a mobile's membership of THIS guild, not of the mobile, and every other actor the bridge writes is a bystander, a killer or a governor. Both files carry the trap this found, because it is the kind of thing a consumer gets wrong silently: **`PlayerMobile.GuildRank` returns Leader for anyone at GameMaster or above, whatever their real rank.** It is a gameplay convenience so staff can operate a guild stone, and the true value has no accessor -- so the bridge omits the rank entirely for a staff account rather than publish a leadership claim it knows is false. An absent rank therefore means "not known", and a consumer must read it as neither 0 (which silently demotes them) nor leadership (which republishes the lie). INTEGRATION.md also states the other half plainly for an outside integrator: `guild.update`'s single `leader` is the founder-leader, not the set of leaders, so "who leads this guild" is a read of the roster's ranks. Recorded too: the sidecar needs no change and no store migration, because it treats roster members as opaque values and never reads a field inside one. That is the forwarder design paying off, and it is worth having written down the next time someone adds a member field. Pairs with servuo-plugins (the emitter) and Module-uo (the ingest and the provider). Co-Authored-By: Claude <noreply@anthropic.com>
270 lines
15 KiB
Markdown
270 lines
15 KiB
Markdown
# Protocol 4 — Guild membership on the wire
|
||
|
||
**Status:** Feature-complete on `edge`. All work lands on an `edge` branch in each repo; `edge` → `main` is the v4 cutover.
|
||
**Date:** 2026-08-17
|
||
**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), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||
|
||
---
|
||
|
||
## 1. Why 4
|
||
|
||
Protocol 2 gave the website a guild board: name, abbreviation, leader, alliance, and a member
|
||
**count**. It could say a guild had 155 members. It could not say who they were, and there was no
|
||
event for anyone leaving one.
|
||
|
||
That gap was known when 2.0 shipped. [`PROTOCOL_2.md` §10.1](PROTOCOL_2.md) sketched exactly this
|
||
design — hold a member-serial set, diff it each sweep, emit `guild.join`/`guild.leave` — and then
|
||
2.0 shipped only the half that needed no new state, folding membership into the board signature as a
|
||
serial *sum*. Protocol 4 builds the rest of what §10.1 described.
|
||
|
||
The immediate consumer is a richer public Guilds page, which is worth the bump on its own merit. The
|
||
reason it is being built **now** is that [`../website/TEAMS.md`](../website/TEAMS.md) needs it:
|
||
platform Teams are sourced from game guilds, and nothing in Team core can be built against a count.
|
||
That is a dependency, not a coupling — nothing in this protocol version knows what a Team is.
|
||
|
||
**Two kinds, both additive:**
|
||
|
||
| Kind | Shape | Purpose |
|
||
|---|---|---|
|
||
| `guild.roster` | full member list, possibly split across frames | the membership itself; supersedes whatever was held |
|
||
| `guild.leave` | one departing member | the real-time counterpart to the existing `guild.join` |
|
||
|
||
Nothing existing changed shape. `GET /guilds` grows a `roster` key; a v3 consumer that ignores it
|
||
keeps working.
|
||
|
||
---
|
||
|
||
## 2. The shard side
|
||
|
||
`BridgeSocial.cs` holds each guild's member serial **set** instead of folding it into the signature
|
||
as a sum. Two consequences, both the point of the change:
|
||
|
||
- **A set comparison cannot collide.** The old sum could: one member joining and another leaving
|
||
between two sweeps offset each other, and the guild read as unchanged.
|
||
- **A set can be differenced.** Departures are the prior set minus the current one — which is what
|
||
makes a per-member `guild.leave` possible without a core tap, since ServUO raises no event for
|
||
leaving, disbanding, or a leader change.
|
||
|
||
A changed set re-emits `guild.roster`, the whole member list. That is what lets `guild.leave` stay
|
||
**advisory**: a consumer building a "so-and-so left" feed wants the individual events, but a consumer
|
||
holding a membership table only needs the roster, so nothing downstream has to replay deltas to
|
||
remain correct. A dropped `guild.leave` costs latency, never accuracy.
|
||
|
||
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving. An unknown
|
||
roster becoming known is not 155 people leaving at once.
|
||
|
||
### 2.1 Frames are capped, and split when they must be
|
||
|
||
A roster is the only fat frame this bridge emits. Measured against a real 155-member guild: **10,812
|
||
bytes, about 69 bytes per member.** The sidecar's `read_line` has no length bound, so an uncapped
|
||
roster is an unbounded line.
|
||
|
||
Members per frame are therefore capped (`Bridge.GuildRosterMembersPerLine`, default 500 ≈ 35 KB), and
|
||
a guild over the cap is split into frames carrying `seq`, `more` and `total`:
|
||
|
||
```jsonc
|
||
{"t":1786988708513,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
||
"total":155,"seq":0,"more":true,"members":[ /* 50 actor objects */ ]}
|
||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
||
"total":155,"seq":1,"more":true,"members":[ /* 50 */ ]}
|
||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
||
"total":155,"seq":2,"more":true,"members":[ /* 50 */ ]}
|
||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
||
"total":155,"seq":3,"more":false,"members":[ /* 5 */ ]}
|
||
```
|
||
|
||
Reading the flags: **`seq` 0 begins a roster and supersedes whatever was held for that guild**;
|
||
`more: false` ends it. Every realistic guild is inside the cap and emits exactly one frame with
|
||
`seq: 0, more: false` — the same shape as if chunking did not exist. A guild with no members still
|
||
emits one frame with an empty array, or a consumer could never learn that a roster it holds has
|
||
emptied.
|
||
|
||
`guild.leave` is a small frame naming the departed serial:
|
||
|
||
```jsonc
|
||
{"t":1786988770099,"kind":"guild.leave","id":1,"name":"The Silver Hand","who":"0x1F5"}
|
||
```
|
||
|
||
### 2.2 The reconnect baseline is spread
|
||
|
||
`BridgeLink.OnConnected` clears the diff caches, so after a reconnect **every** guild reads as
|
||
changed at once. The outbound queue is not the risk — the cap is 10,000 *lines* and a few hundred
|
||
guilds is a few hundred lines — but building hundreds of fat JSON frames in a single Core-thread tick
|
||
is exactly the stall this bridge exists to avoid.
|
||
|
||
So at most `Bridge.GuildRosterGuildsPerTick` guilds (default 25) emit a roster per sweep. A guild over
|
||
budget keeps its previously held member set, so it still reads as changed on the next pass; its
|
||
`guild.update` has already gone, so the board's counts are current either way. While a baseline is
|
||
draining the sweep re-arms itself after 2 s rather than waiting a full `GuildSweepSeconds`, so
|
||
catch-up takes seconds instead of one sweep interval per batch.
|
||
|
||
### 2.3 What a roster member carries
|
||
|
||
Each entry is the standard actor object — `serial`, `name`, `player`, plus `acct` when the mobile has
|
||
an account and `webId` when that account is linked — **and the member's rank in this guild**:
|
||
|
||
```jsonc
|
||
{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true,
|
||
"rank":4,"rankCliloc":1062959}
|
||
```
|
||
|
||
`acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test
|
||
world contains such mobiles. Consumers must not assume it is present.
|
||
|
||
These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and
|
||
deciding who may see them is the website's job. See §4.
|
||
|
||
#### Rank
|
||
|
||
> **Added 2026-08-17, amending Protocol 4 in place.** The version is **not** bumped: Protocol 4 has
|
||
> not reached `main`, and a protocol owes a bump only once it has been released. The roster shipped
|
||
> without rank, and Teams phase 2 then found the consequence — the website could learn leadership
|
||
> only from the board's single `leader` field, so it could name exactly one leader while a UO guild
|
||
> routinely has several.
|
||
|
||
| Field | Shape | When |
|
||
| --- | --- | --- |
|
||
| `rank` | integer 0–4, 4 being Leader (`RankDefinition.Ranks`) | whenever the rank is known |
|
||
| `rankCliloc` | integer — the cliloc the game names the rank with | when the rank's name is a cliloc, i.e. the five stock ranks |
|
||
| `rankName` | string | when a shard's custom rank definition carries a literal name instead |
|
||
|
||
**Rank is on roster members only.** It is a property of a mobile's membership of *this* guild, not of
|
||
the mobile, and every other actor the bridge writes is a bystander, a killer or a governor, where
|
||
guild rank is meaningless.
|
||
|
||
**Only the raw rank is emitted, never a resolved label.** ServUO names the five stock ranks with
|
||
clilocs (1062959–1062963) and ships no text for them, so the shard cannot produce "Warlord" without a
|
||
client-file table it does not have. The website module does have one, and resolving a game term is
|
||
its job in any case.
|
||
|
||
**An absent rank means "not known", and a consumer must not read it as 0 or as leadership.** It has
|
||
one deliberate cause, which is the trap this amendment found: `PlayerMobile.GuildRank` returns
|
||
`RankDefinition.Leader` for anyone at **GameMaster or above**, whatever their real rank. That is a
|
||
gameplay convenience so staff can operate a guild stone, not a claim about who leads the guild — and
|
||
the true value is in a private field with no accessor. So the bridge writes **no rank at all** for a
|
||
staff account rather than publishing a leadership claim it knows to be false. A staff member who
|
||
genuinely leads their guild therefore appears unranked, which is a visible gap rather than a lie on a
|
||
public roster.
|
||
|
||
**The sidecar is unaffected.** It treats roster members as opaque values and never reads a field
|
||
inside one, so a new member field needs no sidecar change and no store migration — which is the
|
||
forwarder design (§3) doing its job.
|
||
|
||
---
|
||
|
||
## 3. The sidecar side
|
||
|
||
`guild.roster` writes a **`members` column on the `guilds` board**, not a field inside the existing
|
||
`json` column. That column holds the verbatim `guild.update` line, and a roster written into it would
|
||
clobber the snapshot — name, abbreviation, leader, online count — that `guild.update` owns. Two
|
||
writers across two columns of one row keeps both as plain upserts: neither reads the other's value
|
||
first, so there is no read-modify-write and no ordering requirement between the two kinds. Either can
|
||
arrive first.
|
||
|
||
`GET /guilds` folds the roster back in as `roster` at read time. A guild that has had a
|
||
`guild.update` but no `guild.roster` yet simply has **no `roster` key** — the honest representation
|
||
of "not known", and deliberately distinct from a guild whose roster is genuinely empty.
|
||
|
||
`guild.leave` gets **no board projection at all**. The sidecar persists and broadcasts it like any
|
||
event and leaves the board alone; the roster self-corrects on the next `guild.roster`, which the
|
||
shard re-emits whenever the member set changes. Keeping the delta out of the projection is what keeps
|
||
the sidecar a forwarder rather than a thing that maintains state.
|
||
|
||
### 3.1 Split rosters are reassembled in memory
|
||
|
||
A roster split across frames is reassembled **before** it is stored, and written once on the frame
|
||
that closes it. Appending to the column per frame was rejected twice over: it would make the write a
|
||
read-modify-write — the exact thing the two-column split exists to avoid — and it would publish a
|
||
torn roster, since a reader hitting `GET /guilds` between frames would see a partial member list
|
||
presented as the whole truth.
|
||
|
||
This is transport-level reassembly, the same category of work as turning bytes into a line, and it
|
||
holds nothing once a roster is complete. The ordinary single-frame case never enters the buffer at
|
||
all. Around it: a fresh `seq: 0` supersedes an abandoned partial, an out-of-order frame discards the
|
||
partial rather than storing one with an undetectable hole, a continuation with no start is ignored, a
|
||
`server.hello` drops every partial (a reconnected shard restarts each roster at 0), and accumulation
|
||
is bounded so a shard that never sends a closing frame cannot grow the buffer without limit.
|
||
|
||
### 3.2 This is the first bump that needed a store migration
|
||
|
||
`store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but **cannot add a
|
||
column to a table that already exists**. Every schema change up to and including Protocol 3.0 added
|
||
whole tables, so this never mattered and `ALTER TABLE` appeared nowhere in the repo's history.
|
||
`guilds.members` is the first column added to an existing table: without a mechanism it would simply
|
||
never reach an installed sidecar, and every roster write would fail.
|
||
|
||
The counter is SQLite's own **`PRAGMA user_version`** — an integer in the database header, so it
|
||
costs no table and cannot drift from the file it describes. Each step runs in a transaction together
|
||
with the bump recording it, so a step lands completely or not at all and an interrupted run resumes
|
||
in the right place.
|
||
|
||
- **A failure aborts startup**, which was already the behaviour. A half-migrated store answers the
|
||
website with confusing partial data, which is worse than being plainly absent — and the shard dials
|
||
*out*, so a sidecar that refuses to start never stalls the game.
|
||
- **A database written by a newer sidecar warns and continues.** Every step is additive, so a newer
|
||
schema has only columns an older reader ignores; refusing to start would turn rolling the binary
|
||
back — a recovery path — into a dead end.
|
||
|
||
`sqlx::migrate!` was considered and rejected: it checksums each migration file, so editing an
|
||
already-released migration hard-fails startup, which is a poor trade for a schema of eleven
|
||
JSON-blob tables.
|
||
|
||
---
|
||
|
||
## 4. Visibility
|
||
|
||
Both kinds map to the existing **`guilds`** feature in the website's visibility framework
|
||
([`v3.md` §3](v3.md)). That mapping is required, not cosmetic: rule 2 fails an *unmapped* kind closed
|
||
to admin-only, which would have quietly kept rosters off the public Guilds page forever.
|
||
|
||
Mapping them is safe because of how the framework strips fields. A roster is the first frame carrying
|
||
locked fields inside an **array** of actors rather than a single nested actor — but the projection
|
||
walker already recurses into arrays and matches `acct`/`webId` **by suffix, on meaning rather than
|
||
spelling**. So a member's account name is stripped below `admin` by exactly the rule that already
|
||
strips `guild.leader.acct`.
|
||
|
||
The website stores `acct`/`web_id` per member, because that is what lets a linked member be matched to
|
||
a site user at all. It never projects them below `admin`. The difference this rule makes is a public
|
||
page listing character names versus one publishing 150 account names, so it carries a test of its own.
|
||
|
||
---
|
||
|
||
## 5. Cross-repo obligations
|
||
|
||
| Repo | Change |
|
||
|---|---|
|
||
| `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** |
|
||
| `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection |
|
||
| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries |
|
||
| `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only |
|
||
| `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` |
|
||
|
||
**`overlay.toml` must move in the same PR as the emitters.** CI folds it into the release manifest and
|
||
the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump
|
||
landing separately would silently fail to compose into a bundle.
|
||
|
||
---
|
||
|
||
## 6. Verification
|
||
|
||
`servuo-plugins` has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not
|
||
evidence and neither is a clean boot. What was actually run:
|
||
|
||
1. **A throwaway one-guild spike first** (TEAMS.md Phase 0), against the real Rust sidecar rather than
|
||
a stub, to retire the unknowns before committing to a four-repo bump. It proved the two-column
|
||
board shape, measured the line, and surfaced §3.2 — the missing migration mechanism — which no
|
||
amount of reading would have.
|
||
2. **A live end-to-end run**: 155 members seeded from real `PlayerMobile`s on the local ServUO tree,
|
||
the cap forced down to 50 so the split path actually fired, producing 50/50/50/5 across four
|
||
frames; two members then removed on a timer, producing exactly two `guild.leave` frames with the
|
||
correct serials and a re-emitted roster at `total: 153`.
|
||
3. **A restart test with the shard stopped first**, so its reconnect could not re-emit and fake
|
||
persistence. The board's row came back byte-identical.
|
||
|
||
Step 2 is what caught the reassembly bug in §3.1: every unit test passed through it, because they all
|
||
exercised a single-frame roster. The case does not arise until a guild exceeds the cap.
|
||
|
||
Still outstanding for the cutover: the five-rung shard visibility walk against a live shard, confirming
|
||
`acct`/`webId` never reach a caller below their rung.
|