diff --git a/overlay.toml b/overlay.toml index 034eae3..0799583 100644 --- a/overlay.toml +++ b/overlay.toml @@ -23,8 +23,8 @@ # manual duty: when the protocol changes, bump it here in the same PR that # changes the emitters, exactly as link bumps PROTOCOL_VERSION. # -# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing). -protocol = 3 +# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave). +protocol = 4 # ── ServUO compatibility ───────────────────────────────────────────────────── # diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 0245269..c79076f 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -34,6 +34,18 @@ PageSweepSeconds=5 # interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample. GuildSweepSeconds=60 +# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits +# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a +# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB, +# past any realistic guild, so the split path is an edge case rather than the norm. +GuildRosterMembersPerLine=500 + +# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar +# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall +# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so +# lowering this slows the catch-up without making the site wait a full sweep interval per batch. +GuildRosterGuildsPerTick=25 + # Town-governor poll. Each city's Governor / election is diffed on this interval to emit # city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine. # Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled). diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index bd1362a..ee067fe 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -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; diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index 9d73789..6c12e55 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -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; + } + /// + /// 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 exists separately + /// rather than 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. + /// + public static StringBuilder Actors( + this StringBuilder sb, string name, IList 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; + } + + /// + /// 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. + /// + 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; } /// Closes the object. The trailing newline is the frame delimiter. diff --git a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs index 48b756d..fd0e092 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs @@ -15,10 +15,14 @@ namespace Server.Custom.Bridge /// 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. A membership change also moves the board - /// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in - /// the next `guild.update`; per-member leave events would need a core tap and are a later - /// refinement (§10.1). + /// 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 @@ -32,7 +36,16 @@ namespace Server.Custom.Bridge // was cleared on reconnect), so its next sweep counts as a change. private static readonly Dictionary _last = new Dictionary(); - private static long _sweeps, _emitted, _removed, _joins; + // 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() { @@ -52,6 +65,7 @@ namespace Server.Custom.Bridge private static void OnConnected() { _last.Clear(); + _members.Clear(); } /// Stops and recreates the timer from current config. Called by `[bridge reload`. @@ -72,8 +86,9 @@ namespace Server.Custom.Bridge public static string Status() { - return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})", - _sweeps, _emitted, _removed, _joins, _last.Count); + 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`. @@ -93,6 +108,12 @@ namespace Server.Custom.Bridge 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; @@ -104,15 +125,64 @@ namespace Server.Custom.Bridge 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; - if (_last.TryGetValue(g.Id, out prior) && prior == sig) + var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig; + + if (!sigChanged && !membersChanged) continue; // unchanged since last emit - _last[g.Id] = sig; - BridgeLink.Emit(WriteGuild(g)); - _emitted++; + 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. @@ -120,9 +190,19 @@ namespace Server.Custom.Bridge 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) { @@ -130,14 +210,15 @@ namespace Server.Custom.Bridge } } - // The volatile fields that define a meaningful change: name, abbreviation, leader, member - // count, the member set (order-independent serial sum), and alliance. - private static string Signature(Guild g) + /// + /// 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) { - long memberSum = 0; - int count = 0; - + var set = new HashSet(); var members = g.Members; + if (members != null) { for (int i = 0; i < members.Count; i++) @@ -145,8 +226,28 @@ namespace Server.Custom.Bridge var m = members[i]; if (m == null) continue; - count++; - unchecked { memberSum += (uint)m.Serial.Value; } + 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++; } } @@ -157,7 +258,6 @@ namespace Server.Custom.Bridge g.Abbreviation ?? "", "|", leaderSerial.ToString(), "|", count.ToString(), "|", - memberSum.ToString(), "|", g.Alliance == null ? "" : (g.AllianceName ?? "")); } @@ -191,6 +291,51 @@ namespace Server.Custom.Bridge 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)