|
|
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
# 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:
|
|
|
|
|
|
|
|
|
|
```jsonc
|
|
|
|
|
{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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.
|