From 8c6db9f0d533600bef9f3e235fe13064eac4a07a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 17:34:51 -0500 Subject: [PATCH] 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++;