Protocol 2 could say how many members a guild had, not who they were, and there is no EventSink for leaving a guild — so PROTOCOL_2.md §10.1 deferred the whole membership half. This closes it. The sweep now holds each guild's member serial **set** instead of folding it into the signature as a sum. That buys two things. A set comparison cannot collide, where a sum could: one member joining and another leaving between two passes offset each other and the guild looked unchanged. And a set can be *differenced*, which is what makes a per-member `guild.leave` possible without a core tap — departures are simply the prior set minus the current one. A changed set also re-emits `guild.roster`, the full member list. That is what lets the departure events stay advisory: a consumer building a "so-and-so left" feed wants them, but a consumer holding a membership table only needs the roster, so nothing downstream has to replay deltas to stay correct. On a guild's first sweep there is no prior set, so nothing is reported as leaving — an unknown roster becoming known is not 155 people leaving at once. A roster is the only fat frame this plugin emits — measured at roughly 69 bytes per member against a real 155-member guild — and the sidecar reads a line with no length bound. So members per frame are capped (default 500, about 35 KB), and a guild over the cap is split into frames carrying `seq`, `more` and `total`. Every realistic guild emits exactly one frame with `seq` 0 and `more` false, which is the same shape as if chunking did not exist. Verified against the real sidecar with the cap forced down to 50, which produced 50/50/50/5 across four frames. The reconnect baseline is spread rather than fired in one pass. `OnConnected` clears the diff caches, so every guild looks changed at once, and building hundreds of fat frames in a single Core-thread tick is exactly the stall this bridge exists to avoid. At most GuildRosterGuildsPerTick guilds emit a roster per sweep; a guild over budget keeps its old member set, so it still reads as changed next pass. The sweep re-arms itself after 2s while a baseline is draining, so catch-up takes seconds rather than one full sweep interval per batch. BridgeJson gained the array writer it never had — there was no way to express a list of objects at all. Every field helper emits a leading `,"name":`, so Actor is split into a bare-object writer that both the single and array forms use. overlay.toml protocol -> 4, in this commit rather than a later one: CI folds it into the release manifest and the installer refuses to pair an overlay and a sidecar that disagree, so a bump landing separately from the emitters would silently fail to compose into a bundle. Verified on a live ServUO shard against the real Rust sidecar (not a stub): 155 members seeded from real PlayerMobiles, four roster frames reassembled to 153 entries on the board after two members were removed, two guild.leave frames with the correct serials, and the departed serials absent from the re-emitted roster. Refs: docs/website/TEAMS.md Part 12 Phase 1 Co-Authored-By: Claude <noreply@anthropic.com>
293 lines
10 KiB
C#
293 lines
10 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.
|
|
/// </summary>
|
|
public static StringBuilder Actors(
|
|
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count)
|
|
{
|
|
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(',');
|
|
|
|
WriteActor(sb, m);
|
|
first = false;
|
|
}
|
|
}
|
|
|
|
sb.Append(']');
|
|
return sb;
|
|
}
|
|
|
|
/// <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("{\"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('}');
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|
|
}
|