Phase 6: town-crier news (website -> game)

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>
This commit is contained in:
2026-07-10 11:56:22 -05:00
parent feeb904bfe
commit e4b62ff5ce
7 changed files with 327 additions and 0 deletions

View File

@@ -20,6 +20,11 @@ namespace Server.Custom.Bridge
public static string LinkUrl { get; private set; }
public static int TownCrierMaxLines { get; private set; }
public static int TownCrierMaxLineLength { get; private set; }
public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { get; private set; }
public static bool Enabled { get; private set; }
public static void Configure()
@@ -42,6 +47,11 @@ namespace Server.Custom.Bridge
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
TownCrierMaxLineLength = Config.Get("Bridge.TownCrierMaxLineLength", 200);
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
if (QueueCap < 16)
QueueCap = 16;
}

View File

@@ -149,6 +149,35 @@ namespace Server.Custom.Bridge
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;

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// Website-published news, pushed into the game's town criers.
///
/// Inbound towncrier.add adds a global entry that every town crier announces until it
/// expires; towncrier.remove pulls one early. Both run on the Core thread (inbound lines are
/// marshaled through Timer.DelayCall before a handler sees them), which is required because
/// AddEntry mutates a shared list and the criers send packets.
///
/// Loopback is the trust boundary, but the caps here (line count/length, active-entry count,
/// duration) are defense in depth: a compromised or buggy sidecar still cannot flood the
/// criers or pin a message forever.
/// </summary>
public static class BridgeTownCrier
{
// Website id -> the entry we created for it, so a later remove can find it.
private static readonly Dictionary<string, TownCrierEntry> _entries =
new Dictionary<string, TownCrierEntry>(StringComparer.Ordinal);
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("towncrier.add", OnAdd);
BridgeBoot.RegisterHandler("towncrier.remove", OnRemove);
}
private static void Reply(string kind, string id, string reason)
{
var sb = BridgeJson.Begin(kind);
if (id != null) sb.Str("id", id);
if (reason != null) sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
private static void OnAdd(Dictionary<string, object> o)
{
var id = BridgeJson.GetString(o, "id");
if (id == null)
{
Reply("towncrier.error", null, "missing id");
return;
}
var lines = BridgeJson.GetStringList(o, "lines");
if (lines.Count == 0)
{
Reply("towncrier.error", id, "no lines");
return;
}
if (lines.Count > BridgeConfig.TownCrierMaxLines)
{
Reply("towncrier.error", id, "too many lines");
return;
}
// Prune expired entries from our map before enforcing the active cap.
PruneExpired();
// Replacing an existing id is fine; otherwise enforce the active cap.
if (!_entries.ContainsKey(id) && _entries.Count >= BridgeConfig.TownCrierMaxActive)
{
Reply("towncrier.error", id, "too many active entries");
return;
}
var clean = new string[lines.Count];
for (int i = 0; i < lines.Count; i++)
{
var line = lines[i] ?? "";
if (line.Length > BridgeConfig.TownCrierMaxLineLength)
line = line.Substring(0, BridgeConfig.TownCrierMaxLineLength);
clean[i] = line;
}
int durationSec = BridgeJson.GetInt(o, "durationSec", 3600);
if (durationSec < 1)
durationSec = 1;
if (durationSec > BridgeConfig.TownCrierMaxDurationSec)
durationSec = BridgeConfig.TownCrierMaxDurationSec;
try
{
// If this id already exists, replace it: remove the old entry first.
TownCrierEntry old;
if (_entries.TryGetValue(id, out old) && old != null)
GlobalTownCrierEntryList.Instance.RemoveEntry(old);
var entry = GlobalTownCrierEntryList.Instance.AddEntry(clean, TimeSpan.FromSeconds(durationSec));
_entries[id] = entry;
Reply("towncrier.ok", id, null);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] towncrier.add threw: {0}", ex.Message);
Reply("towncrier.error", id, "internal error");
}
}
private static void OnRemove(Dictionary<string, object> o)
{
var id = BridgeJson.GetString(o, "id");
if (id == null)
{
Reply("towncrier.error", null, "missing id");
return;
}
TownCrierEntry entry;
if (!_entries.TryGetValue(id, out entry))
{
Reply("towncrier.error", id, "unknown id");
return;
}
_entries.Remove(id);
try
{
if (entry != null)
GlobalTownCrierEntryList.Instance.RemoveEntry(entry);
Reply("towncrier.ok", id, null);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] towncrier.remove threw: {0}", ex.Message);
Reply("towncrier.error", id, "internal error");
}
}
private static void PruneExpired()
{
var doomed = new List<string>();
foreach (var kv in _entries)
{
if (kv.Value == null || kv.Value.Expired)
doomed.Add(kv.Key);
}
foreach (var id in doomed)
_entries.Remove(id);
}
}
}