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:
@@ -85,11 +85,66 @@ namespace Server.Custom.Bridge
|
||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":");
|
||||
WriteActor(sb, m);
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
|
||||
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
|
||||
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
|
||||
/// rather than <see cref="Actor"/> being reused.
|
||||
///
|
||||
/// `count` bounds how many are written, because a roster frame must stay a bounded line
|
||||
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
|
||||
/// than written as null, so the array is always a list of real members and a caller can
|
||||
/// trust its length.
|
||||
/// </summary>
|
||||
public static StringBuilder Actors(
|
||||
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":[");
|
||||
|
||||
if (mobiles != null)
|
||||
{
|
||||
var end = Math.Min(start + count, mobiles.Count);
|
||||
bool first = true;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
var m = mobiles[i];
|
||||
|
||||
if (m == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
|
||||
WriteActor(sb, m);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One bare actor object, with no leading field name: serial, name, account (when there
|
||||
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
|
||||
/// mobile writes null.
|
||||
///
|
||||
/// `acct` and `webId` are the site-identity fields, and they are emitted here
|
||||
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
|
||||
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
|
||||
/// genuinely optional: a PlayerMobile can have no Account at all.
|
||||
/// </summary>
|
||||
private static void WriteActor(StringBuilder sb, Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return sb;
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
@@ -113,7 +168,6 @@ namespace Server.Custom.Bridge
|
||||
|
||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
||||
sb.Append('}');
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||
|
||||
Reference in New Issue
Block a user