BridgeTownCrier handles inbound towncrier.add / towncrier.remove, pushing
website-published news into GlobalTownCrierEntryList so every town crier
announces it until it expires. Both run on the Core thread (required: AddEntry
mutates a shared list and the criers send packets). An id maps to the created
TownCrierEntry so a later remove can pull it, and re-adding an id replaces the
prior entry.
Caps are enforced before touching the shared list -- line count, line length,
active-entry count, duration -- as defense in depth on top of the loopback trust
boundary: a buggy or compromised sidecar still cannot flood the criers or pin a
message forever. Config: Bridge.TownCrierMax{Lines,LineLength,Active,DurationSec}.
Verified with a sending stub and a probe that logs the actual crier list. Replies
and game state agree: add n1 -> towncrier.ok and the entry appears with the exact
lines; add n2 (8 lines over the cap of 6) -> towncrier.error and never enters the
list; remove n1 -> towncrier.ok and the entry is gone; remove unknown ->
towncrier.error. Evidence in docs/PLAN.md §16.
Adds BridgeJson.GetStringList for JSON string arrays, tools/stub_sidecar_crier.ps1,
and tools/scaffolding/BridgeCrierProbe.cs. This closes the pure-plugin inbound
work; only the PlayerVendorSale core edit (Phase 7) remains on the ServUO side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.3 KiB
C#
199 lines
6.3 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>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;
|
|
}
|
|
}
|
|
}
|
|
}
|