using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Web.Script.Serialization;
namespace Server.Custom.Bridge
{
///
/// 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.
///
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 ----
/// Opens an object and writes the `t` and `kind` fields.
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;
}
/// Serial as the canonical "0x1A2B" string the sidecar keys on.
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;
}
///
/// 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.
///
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.
///
/// `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,
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;
}
///
/// A named array of actor objects each carrying a damage total — a boss kill's damage
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
/// merely listed.
///
/// The pairs are written in the order given, so the CALLER owns the sort. That is
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
/// to report the numbers it holds rather than to decide what counts as a contribution.
///
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
/// every other actor is. A shard that considers the whole table too revealing hides it
/// with one field rule rather than by dropping the kind.
///
public static StringBuilder Damagers(
this StringBuilder sb, string name, IList> pairs, int count)
{
sb.Append(",\"").Append(name).Append("\":[");
if (pairs != null)
{
var end = Math.Min(count, pairs.Count);
bool first = true;
for (int i = 0; i < end; i++)
{
var m = pairs[i].Key;
if (m == null)
continue;
if (!first)
sb.Append(',');
sb.Append('{');
WriteActorFields(sb, m);
sb.Append(",\"damage\":").Append(pairs[i].Value);
sb.Append('}');
first = false;
}
}
sb.Append(']');
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`
/// 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.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 ?? "");
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");
}
/// Closes the object. The trailing newline is the frame delimiter.
public static string End(this StringBuilder sb)
{
sb.Append('}');
return sb.ToString();
}
///
/// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
/// For the hand-built arrays the event plane emits, where would
/// throw on the null a nullable field is entitled to be.
///
public static void Text(StringBuilder sb, string value)
{
if (value == null)
sb.Append("null");
else
Escape(sb, value);
}
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('"');
}
// ---- rewriting an already-built line (protocol 6) ----
//
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
// parse the line, edit the dictionary and re-serialize, but a round trip through
// JavaScriptSerializer would silently renormalise every number and string in a reply this
// file went to the trouble of writing by hand. These two edit the text instead, so a
// replayed reply is byte-for-byte the original apart from the field that had to change.
///
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
/// finding the value's end. Returns null if the field is not present as a string —
/// never a half-rewritten line.
///
public static string RewriteStringField(string line, string name, string value)
{
if (line == null || name == null || value == null)
return null;
// The leading comma is part of the needle: every top-level field is written by Str()
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
// is the cheapest thing that stops the search matching the same text inside a value.
var needle = ",\"" + name + "\":\"";
int at = line.IndexOf(needle, StringComparison.Ordinal);
if (at < 0)
return null;
int valueStart = at + needle.Length;
int i = valueStart;
while (i < line.Length)
{
char c = line[i];
if (c == '\\')
{
i += 2; // an escape consumes the next character, whatever it is
continue;
}
if (c == '"')
break;
i++;
}
if (i >= line.Length)
return null; // unterminated: refuse rather than guess
var sb = new StringBuilder(line.Length + value.Length);
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
Escape(sb, value);
sb.Append(line, i + 1, line.Length - i - 1);
return sb.ToString();
}
///
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
/// is not one, so a malformed reply is passed through rather than corrupted further.
///
public static string WithTrueFlag(string line, string name)
{
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
return line;
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
}
// ---- inbound ----
///
/// 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.
///
public static Dictionary Parse(string line)
{
if (String.IsNullOrEmpty(line))
return null;
try
{
if (_parser == null)
{
_parser = new JavaScriptSerializer();
_parser.MaxJsonLength = 1 << 20;
}
return _parser.Deserialize>(line);
}
catch
{
return null;
}
}
public static string GetString(Dictionary 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);
}
///
/// 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.
///
public static List GetStringList(Dictionary o, string key)
{
var result = new List();
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;
}
///
/// Extracts a JSON array of OBJECTS, as a list of dictionaries.
///
/// `JavaScriptSerializer` already materializes a nested object as another
/// `Dictionary<string, object>` when the target is `object`, so this needs no
/// parser work -- only the same defensive walk `GetStringList` does. Anything in the
/// array that is not an object is skipped rather than failing the whole field: a
/// malformed row in an oracle's dialogue should cost that row, not the NPC.
///
/// Returns an empty list for a missing or non-array value, never null.
///
public static List> GetObjectList(
Dictionary o, string key)
{
var result = new List>();
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)
{
var row = item as Dictionary;
if (row != null)
result.Add(row);
}
return result;
}
public static int GetInt(Dictionary 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;
}
}
///
/// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
/// hands a large JSON number back as a long or a decimal depending on its magnitude, so
/// the conversion is done rather than the cast attempted.
///
public static long GetLong(Dictionary o, string key, long fallback)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return fallback;
try
{
return Convert.ToInt64(v, CultureInfo.InvariantCulture);
}
catch
{
return fallback;
}
}
///
/// A lease VALUE arrives as text on the wire whatever its declared type (see
/// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
/// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
/// must read the same bytes the same way as one that is not.
///
public static double GetDouble(Dictionary o, string key, double fallback)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return fallback;
try
{
return Convert.ToDouble(v, CultureInfo.InvariantCulture);
}
catch
{
return fallback;
}
}
}
}