feat(bridge)!: guild rosters and per-member leaves, on protocol 4
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>
This commit is contained in:
@@ -38,6 +38,10 @@ namespace Server.Custom.Bridge
|
||||
public static int PointsSweepSeconds { get; private set; }
|
||||
public static int MarketSweepSeconds { get; private set; }
|
||||
|
||||
// ---- guild rosters (Protocol 4) ----
|
||||
public static int GuildRosterMembersPerLine { get; private set; }
|
||||
public static int GuildRosterGuildsPerTick { get; private set; }
|
||||
|
||||
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
||||
public static bool MarketEnabled { get; private set; }
|
||||
public static int MarketSweepBatch { get; private set; }
|
||||
@@ -114,6 +118,24 @@ namespace Server.Custom.Bridge
|
||||
if (GuildSweepSeconds < 1)
|
||||
GuildSweepSeconds = 1;
|
||||
|
||||
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
|
||||
// per member — and the sidecar reads a line with no length bound. The cap turns an
|
||||
// unbounded frame into a bounded one; a guild above it is split across continuation
|
||||
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
|
||||
// an edge case rather than the norm.
|
||||
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
|
||||
if (GuildRosterMembersPerLine < 16)
|
||||
GuildRosterMembersPerLine = 16;
|
||||
|
||||
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
|
||||
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
|
||||
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
|
||||
// re-arms itself promptly while a baseline is still draining, so this throttles the work
|
||||
// without making the site wait a full sweep interval per batch.
|
||||
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
|
||||
if (GuildRosterGuildsPerTick < 1)
|
||||
GuildRosterGuildsPerTick = 1;
|
||||
|
||||
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
||||
if (CitySweepSeconds < 1)
|
||||
CitySweepSeconds = 1;
|
||||
|
||||
Reference in New Issue
Block a user