feat(bridge)!: guild rosters and per-member leaves, on protocol 4 #12

Merged
whitlocktech merged 1 commits from feat/teams-phase1-guild-roster into edge 2026-08-17 19:28:41 +00:00
Member

Teams Phase 1, shard half. Targets edge; edgemain is the v4 cutover.

Spec: docs/link/v4.md · plan: docs/website/TEAMS.md Part 12 Phase 1

What

Protocol 2 could say how many members a guild had, not who they were, and ServUO raises no event for leaving a guild — so PROTOCOL_2.md §10.1 deferred the membership half. This builds it.

The sweep holds each guild's member serial set instead of folding it into the signature as a sum. A set comparison cannot collide, where a sum could: one member joining and another leaving between two passes offset each other and the guild looked unchanged. And a set can be differenced, which is what makes a per-member guild.leave possible without a core tap.

A changed set also re-emits guild.roster, the full member list, so departures stay advisory — a consumer holding a membership table never has to replay deltas to stay correct.

Frames are capped

A roster is the only fat frame this plugin emits — measured at ~69 bytes/member against a real 155-member guild — and the sidecar's read_line has no length bound. Members per frame are capped (default 500 ≈ 35 KB); a guild over the cap splits into frames carrying seq/more/total. Every realistic guild emits one frame with seq: 0, more: false, the same shape as if chunking did not exist.

The reconnect baseline is spread

OnConnected clears the diff caches, so every guild reads as changed at once. At most GuildRosterGuildsPerTick guilds (default 25) emit a roster per sweep, and the sweep re-arms after 2s while draining — so catch-up takes seconds rather than one full sweep interval per batch.

Notes for review

  • overlay.toml protocol → 4 is in this PR deliberately. CI folds it into the release manifest and the installer refuses to pair an overlay and sidecar that disagree, so a bump landing separately would silently fail to compose into a bundle.
  • BridgeJson had no array writer at all — no way to express a list of objects. Actor is split into a bare-object writer so both the single and array forms can share it.
  • acct/webId are emitted unconditionally by design: the sidecar is a forwarder and the website projects per the visibility rungs.

Verification

This repo has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not evidence. Run against a live ServUO shard and the real Rust sidecar (not a stub):

  • 155 members seeded from real PlayerMobiles; cap forced to 50 so the split path fired → 50/50/50/5 across four frames, seq 0–3, more false only on the last.
  • Two members removed on a timer → exactly two guild.leave frames with the correct serials, and a re-emitted roster at total: 153 with both absent.
  • Roster reassembled to 153 entries on the sidecar's board, with guild.update's name/abbr/counts intact beside it.

Still outstanding for the cutover: the five-rung shard visibility walk.


AI-assisted: written with Claude Code. Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

Teams **Phase 1**, shard half. Targets `edge`; `edge` → `main` is the v4 cutover. Spec: [`docs/link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v4.md) · plan: `docs/website/TEAMS.md` Part 12 Phase 1 ## What Protocol 2 could say how many members a guild had, not who they were, and ServUO raises no event for leaving a guild — so `PROTOCOL_2.md` §10.1 deferred the membership half. This builds it. The sweep holds each guild's member serial **set** instead of folding it into the signature as a sum. A set comparison cannot collide, where a sum could: one member joining and another leaving between two passes offset each other and the guild looked unchanged. And a set can be *differenced*, which is what makes a per-member `guild.leave` possible without a core tap. A changed set also re-emits `guild.roster`, the full member list, so departures stay advisory — a consumer holding a membership table never has to replay deltas to stay correct. ## Frames are capped A roster is the only fat frame this plugin emits — **measured at ~69 bytes/member against a real 155-member guild** — and the sidecar's `read_line` has no length bound. Members per frame are capped (default 500 ≈ 35 KB); a guild over the cap splits into frames carrying `seq`/`more`/`total`. Every realistic guild emits one frame with `seq: 0, more: false`, the same shape as if chunking did not exist. ## The reconnect baseline is spread `OnConnected` clears the diff caches, so every guild reads as changed at once. At most `GuildRosterGuildsPerTick` guilds (default 25) emit a roster per sweep, and the sweep re-arms after 2s while draining — so catch-up takes seconds rather than one full sweep interval per batch. ## Notes for review - **`overlay.toml` protocol → 4 is in this PR deliberately.** CI folds it into the release manifest and the installer refuses to pair an overlay and sidecar that disagree, so a bump landing separately would silently fail to compose into a bundle. - `BridgeJson` had **no array writer at all** — no way to express a list of objects. `Actor` is split into a bare-object writer so both the single and array forms can share it. - `acct`/`webId` are emitted unconditionally by design: the sidecar is a forwarder and the website projects per the visibility rungs. ## Verification This repo has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not evidence. Run against a live ServUO shard and the **real** Rust sidecar (not a stub): - 155 members seeded from real `PlayerMobile`s; cap forced to 50 so the split path fired → **50/50/50/5 across four frames**, `seq` 0–3, `more` false only on the last. - Two members removed on a timer → **exactly two `guild.leave` frames** with the correct serials, and a re-emitted roster at `total: 153` with both absent. - Roster reassembled to 153 entries on the sidecar's board, with `guild.update`'s name/abbr/counts intact beside it. Still outstanding for the cutover: the five-rung shard visibility walk. --- AI-assisted: written with Claude Code. Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-17 18:01:48 +00:00
Protocol 2 could say how many members a guild had, not who they were, and there
is no EventSink for leaving a guild — so PROTOCOL_2.md §10.1 deferred the whole
membership half. This closes it.

The sweep now holds each guild's member serial **set** instead of folding it into
the signature as a sum. That buys two things. A set comparison cannot collide,
where a sum could: one member joining and another leaving between two passes
offset each other and the guild looked unchanged. And a set can be *differenced*,
which is what makes a per-member `guild.leave` possible without a core tap —
departures are simply the prior set minus the current one.

A changed set also re-emits `guild.roster`, the full member list. That is what
lets the departure events stay advisory: a consumer building a "so-and-so left"
feed wants them, but a consumer holding a membership table only needs the roster,
so nothing downstream has to replay deltas to stay correct. 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.

A roster is the only fat frame this plugin emits — measured at roughly 69 bytes
per member against a real 155-member guild — and the sidecar reads a line with no
length bound. So members per frame are capped (default 500, about 35 KB), and a
guild over the cap is split into frames carrying `seq`, `more` and `total`. Every
realistic guild emits exactly one frame with `seq` 0 and `more` false, which is
the same shape as if chunking did not exist. Verified against the real sidecar
with the cap forced down to 50, which produced 50/50/50/5 across four frames.

The reconnect baseline is spread rather than fired in one pass. `OnConnected`
clears the diff caches, so every guild looks changed at once, and building
hundreds of fat frames in a single Core-thread tick is exactly the stall this
bridge exists to avoid. At most GuildRosterGuildsPerTick guilds emit a roster per
sweep; a guild over budget keeps its old member set, so it still reads as changed
next pass. The sweep re-arms itself after 2s while a baseline is draining, so
catch-up takes seconds rather than one full sweep interval per batch.

BridgeJson gained the array writer it never had — there was no way to express a
list of objects at all. Every field helper emits a leading `,"name":`, so Actor
is split into a bare-object writer that both the single and array forms use.

overlay.toml protocol -> 4, in this commit rather than a later one: CI folds it
into the release manifest and the installer refuses to pair an overlay and a
sidecar that disagree, so a bump landing separately from the emitters would
silently fail to compose into a bundle.

Verified on a live ServUO shard against the real Rust sidecar (not a stub): 155
members seeded from real PlayerMobiles, four roster frames reassembled to 153
entries on the board after two members were removed, two guild.leave frames with
the correct serials, and the departed serials absent from the re-emitted roster.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 65562eea40 into edge 2026-08-17 19:28:41 +00:00
whitlocktech deleted branch feat/teams-phase1-guild-roster 2026-08-17 19:28:42 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#12
No description provided.