Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
wtclaude cc4f58317e 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>
2026-08-17 12:52:07 -05:00

364 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Server.Guilds;
namespace Server.Custom.Bridge
{
/// <summary>
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
///
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
/// so joined" feed does not wait for the next sweep.
///
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
/// member serial **set** rather than a sum of it, so a change is detected by set comparison
/// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
/// departures are recoverable by difference — 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 the board self-corrects and nothing downstream has to replay deltas to stay right.
///
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
/// guild, would look like every guild being created at once.
/// </summary>
public static class BridgeSocial
{
private static Timer _timer;
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
// was cleared on reconnect), so its next sweep counts as a change.
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
// guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
// departure can be recovered as a set difference; see the class remarks.
private static readonly Dictionary<int, HashSet<int>> _members =
new Dictionary<int, HashSet<int>>();
private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
// Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
// instead of leaving the site a sweep interval behind. See GuildSweep.
private static bool _draining;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.JoinGuild += OnJoinGuild;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_last.Clear();
_members.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
GuildSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format(
"guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
_sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
GuildSweep();
}
private static void GuildSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
var seen = new HashSet<int>();
// Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
// right after a reconnect, and a roster is this plugin's only fat frame, so the
// baseline is spread over several passes rather than built in one Core-thread tick.
var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
var deferred = false;
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
// and treating them as absent lets the "gone" pass below emit guild.remove.
if (g == null || g.Disbanded)
continue;
seen.Add(g.Id);
var current = MemberSerials(g);
HashSet<int> priorMembers;
var known = _members.TryGetValue(g.Id, out priorMembers);
var membersChanged = !known || !priorMembers.SetEquals(current);
var sig = Signature(g);
string prior;
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
if (!sigChanged && !membersChanged)
continue; // unchanged since last emit
if (sigChanged)
{
_last[g.Id] = sig;
BridgeLink.Emit(WriteGuild(g));
_emitted++;
}
if (!membersChanged)
continue;
// Over budget: leave _members untouched so this guild is still "changed" next
// pass and gets its roster then. The guild.update above has already gone, so the
// board's counts are current either way.
if (rosterBudget <= 0)
{
deferred = true;
continue;
}
rosterBudget--;
// Departures, per member, before the roster that supersedes them: a consumer
// building a "so-and-so left" feed needs the individual events, while a consumer
// holding the membership table only needs the roster. On the very first sweep for
// a guild there is no prior set, so nothing is reported as having left — an
// unknown roster becoming known is not 155 people leaving.
if (known)
{
foreach (var serial in priorMembers)
{
if (current.Contains(serial))
continue;
BridgeLink.Emit(BridgeJson.Begin("guild.leave")
.Num("id", g.Id)
.Str("name", g.Name)
.Ser("who", (Serial)serial)
.End());
_leaves++;
}
}
EmitRoster(g);
_members[g.Id] = current;
}
// Anything tracked last sweep but not seen now has disbanded or been removed.
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var id in gone)
{
_last.Remove(id);
_members.Remove(id);
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
_removed++;
}
// Re-arm promptly while a baseline is still draining. Without this the remaining
// guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
// hours to publish its rosters after a reconnect instead of seconds. The sweep is
// idempotent, so an extra pass that finds nothing changed costs a few field reads.
_draining = deferred;
if (deferred)
Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
}
}
/// <summary>
/// The guild's live member serials. Held per guild between sweeps so a membership change
/// yields both the fact that it changed and *who* left (Protocol 4).
/// </summary>
private static HashSet<int> MemberSerials(Guild g)
{
var set = new HashSet<int>();
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
var m = members[i];
if (m == null)
continue;
set.Add(m.Serial.Value);
}
}
return set;
}
// The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
// leader, member count and alliance. Membership is no longer folded in here as a serial sum —
// the sweep compares the real member set instead, which cannot collide the way a sum can when
// one member joins and another leaves between two passes.
private static string Signature(Guild g)
{
int count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
if (members[i] != null)
count++;
}
}
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
return String.Concat(
g.Name ?? "", "|",
g.Abbreviation ?? "", "|",
leaderSerial.ToString(), "|",
count.ToString(), "|",
g.Alliance == null ? "" : (g.AllianceName ?? ""));
}
private static string WriteGuild(Guild g)
{
int online = 0, count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
var m = members[i];
if (m == null)
continue;
count++;
if (m.NetState != null)
online++;
}
}
var sb = BridgeJson.Begin("guild.update")
.Num("id", g.Id)
.Str("name", g.Name)
.Str("abbr", g.Abbreviation)
.Num("members", count)
.Num("online", online)
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
sb.Actor("leader", g.Leader);
return sb.End();
}
/// <summary>
/// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
///
/// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
/// the sidecar reads a line with no length bound, so the member count per line is capped
/// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
/// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
/// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
/// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
/// `more` false, which is the same shape as if chunking did not exist.
/// </summary>
private static void EmitRoster(Guild g)
{
var members = g.Members;
var total = members == null ? 0 : members.Count;
var perLine = BridgeConfig.GuildRosterMembersPerLine;
var seq = 0;
var start = 0;
// do/while, not while: a guild with no members must still emit one empty roster frame,
// or a consumer could never learn that a roster it holds has emptied.
do
{
var more = start + perLine < total;
var sb = BridgeJson.Begin("guild.roster")
.Num("id", g.Id)
.Str("name", g.Name)
.Str("abbr", g.Abbreviation)
.Num("total", total)
.Num("seq", seq)
.Bool("more", more);
sb.Actors("members", members, start, perLine);
BridgeLink.Emit(sb.End());
_rosters++;
start += perLine;
seq++;
}
while (start < total);
}
// ---- real-time join ----
private static void OnJoinGuild(JoinGuildEventArgs e)
{
try
{
if (e == null || e.Mobile == null)
return;
var g = e.Guild as Guild;
var sb = BridgeJson.Begin("guild.join");
if (g != null)
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
sb.Actor("who", e.Mobile);
BridgeLink.Emit(sb.End());
_joins++;
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
}
}
}
}