feat(bridge): carry guild rank on roster members

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:34:51 -05:00
parent 65562eea40
commit 8c6db9f0d5
2 changed files with 92 additions and 5 deletions

View File

@@ -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.
/// </summary>
public static StringBuilder Actors(
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count)
this StringBuilder sb, string name, IList<Mobile> 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(',');
if (withGuildRank)
WriteGuildMember(sb, m);
else
WriteActor(sb, m);
first = false;
}
}
@@ -129,6 +139,66 @@ namespace Server.Custom.Bridge
return sb;
}
/// <summary>
/// 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 (10629591062963) 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, 04, 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.
/// </summary>
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('}');
}
/// <summary>
/// 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('}');
}
/// <summary>
/// The actor fields, with no braces, so a caller can add its own.
///
/// Split out for <see cref="WriteGuildMember"/>, 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.
/// </summary>
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('}');
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>

View File

@@ -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++;