feat(bridge)!: protocol 4 — guild rosters and per-member leaves (Teams cutover 1/6) #14

Merged
whitlocktech merged 4 commits from edge into main 2026-08-19 08:54:45 +00:00
Member

Cutover 1 of 6. The shard half of the Teams bet reaching main.

Two commits, both from Teams phase 1:

  • guild.roster — a new kind carrying the actual member set, which the sweep already had to compute a signature over. Emitted when the set changes and on the reconnect baseline. acct/webId only for members whose game account is linked.
  • guild.leave — the missing counterpart to the existing guild.join, computable from the same set diff. Before this, a leave surfaced only as the member count dropping in the next guild.update.
  • Per-member rank on the roster, so a guild's several leaders are all visible. guild.update is unchanged, so nothing that reads it today has to change.

overlay.toml declares protocol = 4 and link's PROTOCOL_VERSION is 4 on its edge — the pair CI publishes as a bundle only composes if both land. Merge this with RunicGateway/link (cutover 2/6), before the installer.

Spec of record: docs/link/v4.md.

Verification

Phase 0 was a throwaway spike run against the real ServUO tree and the real Rust sidecar precisely because this repo has no CI build and a dynamic rebuild can silently reload a stale Scripts.dll. The roster landed in the store, survived a sidecar restart and came back out of GET /guilds. Phase 11's rig walk exercised the same path again through the website.

AI disclosure

Written with Claude Code (Opus 5). Commits carry the Co-Authored-By trailer.

**Cutover 1 of 6.** The shard half of the Teams bet reaching `main`. Two commits, both from Teams phase 1: - `guild.roster` — a new kind carrying the actual member set, which the sweep already had to compute a signature over. Emitted when the set changes and on the reconnect baseline. `acct`/`webId` only for members whose game account is linked. - `guild.leave` — the missing counterpart to the existing `guild.join`, computable from the same set diff. Before this, a leave surfaced only as the member count dropping in the next `guild.update`. - Per-member **rank** on the roster, so a guild's several leaders are all visible. `guild.update` is unchanged, so nothing that reads it today has to change. **`overlay.toml` declares `protocol = 4`** and `link`'s `PROTOCOL_VERSION` is 4 on its `edge` — the pair CI publishes as a bundle only composes if both land. **Merge this with `RunicGateway/link` (cutover 2/6), before the installer.** Spec of record: [`docs/link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v4.md). ### Verification Phase 0 was a throwaway spike run against the real ServUO tree and the real Rust sidecar precisely because this repo has no CI build and a dynamic rebuild can silently reload a stale `Scripts.dll`. The roster landed in the store, survived a sidecar restart and came back out of `GET /guilds`. Phase 11's rig walk exercised the same path again through the website. ### AI disclosure Written with Claude Code (Opus 5). Commits carry the `Co-Authored-By` trailer.
wtclaude added 4 commits 2026-08-19 08:52:04 +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>
Reviewed-on: #12
Protocol 4 is still on `edge` and unreleased, so this amends it in place rather
than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is
only owed once a protocol has reached `main`.

Phase 1 shipped the roster member as the standard actor object, which carries no
guild rank. The consequence surfaced in Teams phase 2: the website could only
learn leadership from the board's single `leader` field, so `getTeamLeaders()`
could return exactly one member — while a UO guild routinely has several at rank
4, and TEAMS.md §2.5 treats multiple leaders as the normal case.

Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks)
plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal
string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the
five standard ranks with cliloc ids and ships no text for them, so this shard
cannot produce "Warlord" without a client-file table it does not have. The
website module has one, and resolving a game term is its job in any case.

`withGuildRank` is a parameter on `Actors()` rather than a change to the shared
actor writer. Rank is a property of a mobile's membership of THIS guild, not of
the mobile, and every other actor this bridge writes is a bystander, a killer or
a governor, where guild rank is meaningless. `WriteActor` is split into a
fields-only writer so both forms share one definition of an actor.

## The trap this found

**`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at
GameMaster or above, whatever their actual rank.** It is a gameplay convenience
so staff can operate a guild stone, and it is emphatically not a claim about who
leads the guild -- but it is what the only public accessor returns, and the true
value sits in a private field. Emitting it verbatim would have published every
staff member in a guild as a guild leader on a public website.

Staff are therefore written with no rank fields at all. A staff account that
genuinely leads its guild shows as an unranked member, which is a visible gap
rather than a false claim -- the right way round, given the name on that roster
reaches a public page.

## Verification

This repo has no CI build, so compiling is not evidence. Run against the local
ServUO tree with a throwaway probe that synthesised a guild from real
PlayerMobiles across the rank ladder, with one account promoted to GameMaster.
The emitted frame:

  tester    rank 4  cliloc 1062959   (Leader)
  Seed000A  rank 4  cliloc 1062959   (Leader)  <- two at once, the point of this
  Seed000B  rank 3  cliloc 1062960   (Warlord)
  Seed000C  rank 2  cliloc 1062961   (Emissary)
  Seed001A  rank 1  cliloc 1062962   (Member)
  Seed001B  no rank fields                     <- GameMaster, stored rank 0,
                                                  getter reported rank 4

The probe printed stored vs reported rank per member, so the getter's substitution
is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4
access=GameMaster`. The line parsed as valid JSON.

`dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and
`deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed
without a world save, so the synthetic guild did not persist (Guilds.bin still 0
bytes).

**The sidecar needs no change.** It treats roster members as opaque values and
never reads a field inside one -- `accumulate_roster` moves them and
`upsert_guild_roster` stores them, both by value. That is the forwarder design
paying off.

Refs docs/link/v4.md §2.3

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #13
whitlocktech approved these changes 2026-08-19 08:54:36 +00:00
whitlocktech merged commit 79cc611ee0 into main 2026-08-19 08:54:45 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

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