using System; using System.Collections.Generic; using System.Linq; using Server.Guilds; namespace Server.Custom.Bridge { /// /// 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 , 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. /// 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 _last = new Dictionary(); // 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> _members = new Dictionary>(); 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(); } /// Stops and recreates the timer from current config. Called by `[bridge reload`. 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); } /// Runs one sweep now. Wired into `[bridge sweepnow`. 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(); // 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 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); } } /// /// 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). /// private static HashSet MemberSerials(Guild g) { var set = new HashSet(); 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(); } /// /// 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. /// 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); } } } }