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>
376 lines
14 KiB
C#
376 lines
14 KiB
C#
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 (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.
|
||
/// </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;
|
||
}
|
||
}
|
||
}
|
||
}
|