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:
@@ -22,6 +22,13 @@ EconomySweepSeconds=300
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
# Town-crier news pushed from the website. Caps are defense in depth on top of the
|
||||
# loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers.
|
||||
TownCrierMaxLines=6
|
||||
TownCrierMaxLineLength=200
|
||||
TownCrierMaxActive=20
|
||||
TownCrierMaxDurationSec=86400
|
||||
|
||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
158
overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
Normal file
158
overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user