Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeJson.cs
wtclaude 8c6db9f0d5 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>
2026-08-17 17:34:51 -05:00

376 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Web.Script.Serialization;
namespace Server.Custom.Bridge
{
/// <summary>
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
/// reflection serializer's.
///
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
/// correctness beats speed there, and parsing happens on the reader thread anyway.
/// </summary>
public static class BridgeJson
{
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
[ThreadStatic]
private static JavaScriptSerializer _parser;
public static long NowMs()
{
return (long)(DateTime.UtcNow - Epoch).TotalMilliseconds;
}
// ---- outbound ----
/// <summary>Opens an object and writes the `t` and `kind` fields.</summary>
public static StringBuilder Begin(string kind)
{
var sb = new StringBuilder(256);
sb.Append("{\"t\":").Append(NowMs());
sb.Append(",\"kind\":\"").Append(kind).Append('"');
return sb;
}
public static StringBuilder Str(this StringBuilder sb, string name, string value)
{
sb.Append(",\"").Append(name).Append("\":");
if (value == null)
sb.Append("null");
else
Escape(sb, value);
return sb;
}
public static StringBuilder Num(this StringBuilder sb, string name, long value)
{
sb.Append(",\"").Append(name).Append("\":").Append(value);
return sb;
}
public static StringBuilder Num(this StringBuilder sb, string name, double value)
{
sb.Append(",\"").Append(name).Append("\":")
.Append(value.ToString("R", CultureInfo.InvariantCulture));
return sb;
}
public static StringBuilder Bool(this StringBuilder sb, string name, bool value)
{
sb.Append(",\"").Append(name).Append("\":").Append(value ? "true" : "false");
return sb;
}
/// <summary>Serial as the canonical "0x1A2B" string the sidecar keys on.</summary>
public static StringBuilder Ser(this StringBuilder sb, string name, Serial serial)
{
sb.Append(",\"").Append(name).Append("\":\"0x")
.Append(serial.Value.ToString("X")).Append('"');
return sb;
}
/// <summary>
/// Writes a nested actor object: serial, name, account (when there is one), the linked
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
/// </summary>
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
{
sb.Append(",\"").Append(name).Append("\":");
WriteActor(sb, m);
return sb;
}
/// <summary>
/// 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 <see cref="WriteActor"/> exists separately
/// rather than <see cref="Actor"/> 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.
///
/// `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,
bool withGuildRank = false)
{
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(',');
if (withGuildRank)
WriteGuildMember(sb, m);
else
WriteActor(sb, m);
first = false;
}
}
sb.Append(']');
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`
/// 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.
/// </summary>
private static void WriteActor(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return;
}
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 ?? "");
var acct = m.Account as Accounting.Account;
if (acct != null)
{
sb.Append(",\"acct\":");
Escape(sb, acct.Username);
var webId = BridgeAccountLink.WebIdFor(acct);
if (webId != null)
{
sb.Append(",\"webId\":");
Escape(sb, webId);
}
}
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
public static string End(this StringBuilder sb)
{
sb.Append('}');
return sb.ToString();
}
public static void Escape(StringBuilder sb, string value)
{
sb.Append('"');
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
default:
if (c < ' ')
sb.Append("\\u").Append(((int)c).ToString("x4"));
else
sb.Append(c);
break;
}
}
sb.Append('"');
}
// ---- inbound ----
/// <summary>
/// Parses one line into a dictionary. Returns null on malformed input rather than
/// throwing: a bad line from the sidecar must never reach a game code path.
/// </summary>
public static Dictionary<string, object> Parse(string line)
{
if (String.IsNullOrEmpty(line))
return null;
try
{
if (_parser == null)
{
_parser = new JavaScriptSerializer();
_parser.MaxJsonLength = 1 << 20;
}
return _parser.Deserialize<Dictionary<string, object>>(line);
}
catch
{
return null;
}
}
public static string GetString(Dictionary<string, object> o, string key)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return null;
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
}
/// <summary>
/// Extracts a JSON array of strings. JavaScriptSerializer materializes JSON arrays as
/// object[] (or ArrayList) when the target is object, so handle both and stringify each
/// element. Returns an empty list for a missing or non-array value, never null.
/// </summary>
public static List<string> GetStringList(Dictionary<string, object> o, string key)
{
var result = new List<string>();
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return result;
var enumerable = v as System.Collections.IEnumerable;
if (enumerable == null || v is string)
return result;
foreach (var item in enumerable)
{
if (item == null)
continue;
result.Add(item as string ?? Convert.ToString(item, CultureInfo.InvariantCulture));
}
return result;
}
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return fallback;
try
{
return Convert.ToInt32(v, CultureInfo.InvariantCulture);
}
catch
{
return fallback;
}
}
}
}