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 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(); } 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 ---- /// /// 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; } 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; } } } }