From cc4f58317ef8959598c93b5261bb2ca8be64cfed Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 12:52:07 -0500 Subject: [PATCH 1/2] feat(bridge)!: guild rosters and per-member leaves, on protocol 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- overlay.toml | 4 +- overlay/Config/Bridge.cfg | 12 ++ overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 22 +++ overlay/Scripts/Custom/Bridge/BridgeJson.cs | 58 +++++- overlay/Scripts/Custom/Bridge/BridgeSocial.cs | 185 ++++++++++++++++-- 5 files changed, 257 insertions(+), 24 deletions(-) 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) -- 2.49.1 From 8c6db9f0d533600bef9f3e235fe13064eac4a07a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 17:34:51 -0500 Subject: [PATCH 2/2] feat(bridge): carry guild rank on roster members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 4 is still on `edge` and unreleased, so this amends it in place rather than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is only owed once a protocol has reached `main`. Phase 1 shipped the roster member as the standard actor object, which carries no guild rank. The consequence surfaced in Teams phase 2: the website could only learn leadership from the board's single `leader` field, so `getTeamLeaders()` could return exactly one member — while a UO guild routinely has several at rank 4, and TEAMS.md §2.5 treats multiple leaders as the normal case. Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks) plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the five standard ranks with cliloc ids and ships no text for them, so this shard cannot produce "Warlord" without a client-file table it does not have. The website module has one, and resolving a game term is its job in any case. `withGuildRank` is a parameter on `Actors()` rather than a change to the shared actor writer. Rank is a property of a mobile's membership of THIS guild, not of the mobile, and every other actor this bridge writes is a bystander, a killer or a governor, where guild rank is meaningless. `WriteActor` is split into a fields-only writer so both forms share one definition of an actor. ## The trap this found **`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or above, whatever their actual rank.** It is a gameplay convenience so staff can operate a guild stone, and it is emphatically not a claim about who leads the guild -- but it is what the only public accessor returns, and the true value sits in a private field. Emitting it verbatim would have published every staff member in a guild as a guild leader on a public website. Staff are therefore written with no rank fields at all. A staff account that genuinely leads its guild shows as an unranked member, which is a visible gap rather than a false claim -- the right way round, given the name on that roster reaches a public page. ## Verification This repo has no CI build, so compiling is not evidence. Run against the local ServUO tree with a throwaway probe that synthesised a guild from real PlayerMobiles across the rank ladder, with one account promoted to GameMaster. The emitted frame: tester rank 4 cliloc 1062959 (Leader) Seed000A rank 4 cliloc 1062959 (Leader) <- two at once, the point of this Seed000B rank 3 cliloc 1062960 (Warlord) Seed000C rank 2 cliloc 1062961 (Emissary) Seed001A rank 1 cliloc 1062962 (Member) Seed001B no rank fields <- GameMaster, stored rank 0, getter reported rank 4 The probe printed stored vs reported rank per member, so the getter's substitution is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4 access=GameMaster`. The line parsed as valid JSON. `dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and `deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed without a world save, so the synthetic guild did not persist (Guilds.bin still 0 bytes). **The sidecar needs no change.** It treats roster members as opaque values and never reads a field inside one -- `accumulate_roster` moves them and `upsert_guild_roster` stores them, both by value. That is the forwarder design paying off. Refs docs/link/v4.md §2.3 Co-Authored-By: Claude --- overlay/Scripts/Custom/Bridge/BridgeJson.cs | 91 ++++++++++++++++++- overlay/Scripts/Custom/Bridge/BridgeSocial.cs | 6 +- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index 6c12e55..bb6bd17 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -99,9 +99,15 @@ namespace Server.Custom.Bridge /// (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. + /// + /// `withGuildRank` adds each member's guild rank to their object. It is a parameter + /// rather than always-on because rank is a property of a mobile's membership of THIS + /// guild, not of the mobile — every other actor this bridge writes is a bystander, + /// a killer, a governor, and guild rank is meaningless on all of them. /// public static StringBuilder Actors( - this StringBuilder sb, string name, IList mobiles, int start, int count) + this StringBuilder sb, string name, IList mobiles, int start, int count, + bool withGuildRank = false) { sb.Append(",\"").Append(name).Append("\":["); @@ -120,7 +126,11 @@ namespace Server.Custom.Bridge if (!first) sb.Append(','); - WriteActor(sb, m); + if (withGuildRank) + WriteGuildMember(sb, m); + else + WriteActor(sb, m); + first = false; } } @@ -129,6 +139,66 @@ namespace Server.Custom.Bridge return sb; } + /// + /// A roster member: the standard actor object plus the member's rank in their guild. + /// + /// **Only the raw rank is emitted, never a resolved label.** ServUO names the five + /// standard ranks with cliloc ids (1062959–1062963) and ships no text for them, so the + /// shard cannot produce "Warlord" without a client-file table it does not have. The + /// website module does have one, and resolving a game term is its job in any case. + /// + /// `rank` is the numeric rank, 0–4, with 4 being Leader (`RankDefinition.Ranks`). A + /// custom rank definition may carry a literal string instead of a cliloc, so `rankName` + /// is written when there is one and `rankCliloc` when there is not; a shard that has + /// replaced the rank table therefore keeps its own naming rather than being flattened + /// into the stock five. + /// + /// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose + /// GuildRank is null — is written with no rank fields at all rather than a fabricated + /// default. Absent means "not known", and a consumer that treated a missing rank as 0 + /// would silently demote them. + /// + /// **Staff are deliberately written with no rank, and this is not a rounding error.** + /// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or + /// above, whatever their actual rank — a gameplay convenience so staff can operate a + /// guild stone, and emphatically not a claim about who leads the guild. The true value + /// is in a private field with no accessor, so the only honest options are "Leader" and + /// "not known", and publishing a staff member as a guild leader on a public roster is + /// the worse of the two by a wide margin. A staff account that genuinely leads its guild + /// shows as an unranked member, which is a visible gap rather than a false claim. + /// + private static void WriteGuildMember(StringBuilder sb, Mobile m) + { + if (m == null) + { + sb.Append("null"); + return; + } + + sb.Append('{'); + WriteActorFields(sb, m); + + var pm = m as Server.Mobiles.PlayerMobile; + var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank; + + if (rank != null) + { + sb.Append(",\"rank\":").Append(rank.Rank); + + if (!string.IsNullOrEmpty(rank.Name.String)) + { + sb.Append(",\"rankName\":"); + Escape(sb, rank.Name.String); + } + else if (rank.Name.Number > 0) + { + sb.Append(",\"rankCliloc\":").Append(rank.Name.Number); + } + } + + sb.Append('}'); + } + /// /// 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` @@ -147,7 +217,21 @@ namespace Server.Custom.Bridge return; } - sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + sb.Append('{'); + WriteActorFields(sb, m); + sb.Append('}'); + } + + /// + /// The actor fields, with no braces, so a caller can add its own. + /// + /// Split out for , which is the same object plus guild + /// rank. Note the first field is written WITHOUT a leading comma and every later one + /// with, so this must be the first thing inside its object. + /// + private static void WriteActorFields(StringBuilder sb, Mobile m) + { + sb.Append("\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); sb.Append(",\"name\":"); Escape(sb, m.Name ?? ""); @@ -167,7 +251,6 @@ namespace Server.Custom.Bridge } sb.Append(",\"player\":").Append(m.Player ? "true" : "false"); - sb.Append('}'); } /// 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 fd0e092..b15861f 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs @@ -325,7 +325,11 @@ namespace Server.Custom.Bridge .Num("seq", seq) .Bool("more", more); - sb.Actors("members", members, start, perLine); + // `withGuildRank` — the roster is the one place a member's rank in THIS guild is + // meaningful, and the only frame that carries it. Leadership is rank 4 + // (RankDefinition.Ranks), and a guild can have several members at it, which is why + // the board's single `leader` field was never enough to answer "who leads this". + sb.Actors("members", members, start, perLine, withGuildRank: true); BridgeLink.Emit(sb.End()); _rosters++; -- 2.49.1