Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and
town governors ("mayors"), both outbound diff-board sweeps mirroring the
existing champ board.
Overlay:
- BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update /
guild.remove (full-state upsert; disband detected via Disbanded), plus a
real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only
the load-time factory, so creation is derived sidecar-side from a first-seen
id, as champs do.)
- BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update
(governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled.
- BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both.
- BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s).
- BridgeBoot: both wired into [bridge reload|sweepnow|status.
Sidecar:
- store: guilds + governors board tables with upsert/delete/all.
- main: route guild.update/remove and city.update into the boards.
- web: GET /guilds, GET /governors served from the store (snapshot-companion
rule, so a fresh page or a restarted sidecar hydrates without the shard).
Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints;
PROTOCOL_2.md Part B phase 1 marked built.
Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
7.8 KiB
C#
239 lines
7.8 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 docs/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("\":");
|
|
|
|
if (m == null)
|
|
{
|
|
sb.Append("null");
|
|
return sb;
|
|
}
|
|
|
|
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");
|
|
sb.Append('}');
|
|
return sb;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|
|
}
|