diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 5ceccce..d85df63 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index a369599..5d39760 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -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;
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
index 8c63b06..21a4d27 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
@@ -149,6 +149,35 @@ namespace Server.Custom.Bridge
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
}
+ ///
+ /// 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.
+ ///
+ public static List GetStringList(Dictionary o, string key)
+ {
+ var result = new List();
+
+ 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 o, string key, int fallback)
{
object v;
diff --git a/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs b/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
new file mode 100644
index 0000000..ddbd2a7
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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.
+ ///
+ public static class BridgeTownCrier
+ {
+ // Website id -> the entry we created for it, so a later remove can find it.
+ private static readonly Dictionary _entries =
+ new Dictionary(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 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 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();
+
+ foreach (var kv in _entries)
+ {
+ if (kv.Value == null || kv.Value.Expired)
+ doomed.Add(kv.Key);
+ }
+
+ foreach (var id in doomed)
+ _entries.Remove(id);
+ }
+ }
+}
diff --git a/tools/scaffolding/BridgeCrierProbe.cs b/tools/scaffolding/BridgeCrierProbe.cs
new file mode 100644
index 0000000..5c1d66e
--- /dev/null
+++ b/tools/scaffolding/BridgeCrierProbe.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Text;
+
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Logs the global town-crier entry list every few seconds so a towncrier.add / remove
+ /// round-trip can be observed landing in the actual game state, not just acknowledged.
+ ///
+ /// Test scaffolding. Never deployed. Read-only.
+ ///
+ public static class BridgeCrierProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.CrierProbeOnStart", false))
+ EventSink.ServerStarted += () =>
+ Timer.DelayCall(TimeSpan.FromSeconds(3.0), TimeSpan.FromSeconds(3.0), Dump);
+ }
+
+ private static int _tick;
+
+ private static void Dump()
+ {
+ try
+ {
+ var list = GlobalTownCrierEntryList.Instance;
+ var entries = list == null ? null : list.Entries;
+ int count = entries == null ? 0 : entries.Count;
+
+ var sb = new StringBuilder();
+ sb.AppendFormat("[CrierProbe] tick {0}: {1} entries", ++_tick, count);
+
+ if (entries != null)
+ {
+ for (int i = 0; i < entries.Count; i++)
+ {
+ var e = entries[i];
+ if (e == null || e.Lines == null)
+ continue;
+ sb.AppendFormat(" | [{0}]", String.Join(" / ", e.Lines));
+ }
+ }
+
+ Console.WriteLine(sb.ToString());
+
+ if (_tick >= 6)
+ Timer.DelayCall(TimeSpan.Zero, () => { }); // no-op; probe stops being interesting
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[CrierProbe] FAILED: " + ex);
+ }
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index 3c33838..6269d54 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -11,6 +11,7 @@ These two scripts produced the measured budget in `docs/PLAN.md` ยง1. They are k
| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
+| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
## Deploy overwrites Bridge.cfg
diff --git a/tools/stub_sidecar_crier.ps1 b/tools/stub_sidecar_crier.ps1
new file mode 100644
index 0000000..2529281
--- /dev/null
+++ b/tools/stub_sidecar_crier.ps1
@@ -0,0 +1,64 @@
+param(
+ [int] $Port = 7788,
+ [string] $Log = "$PSScriptRoot\sc_crier.log"
+)
+
+function Say($msg) {
+ for ($i = 0; $i -lt 5; $i++) {
+ try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
+ catch { Start-Sleep -Milliseconds 100 }
+ }
+}
+
+"" | Out-File -FilePath $Log -Encoding utf8
+Say "[crier-sc] starting on 127.0.0.1:$Port"
+
+$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
+$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
+$bound = $false
+for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
+ try { $listener.Start(); $bound = $true } catch { Start-Sleep -Seconds 1 }
+}
+if (-not $bound) { Say "[crier-sc] could not bind"; exit 1 }
+Say "[crier-sc] listening"
+
+$client = $listener.AcceptTcpClient()
+Say "[crier-sc] === shard connected ==="
+$stream = $client.GetStream()
+$stream.ReadTimeout = 1500
+$reader = New-Object System.IO.StreamReader($stream)
+$writer = New-Object System.IO.StreamWriter($stream)
+$writer.AutoFlush = $true
+Start-Sleep -Milliseconds 500
+
+# Blocking read with a timeout, so StreamReader-buffered lines are not missed the way
+# checking $stream.DataAvailable does.
+function Drain($seconds) {
+ $deadline = (Get-Date).AddSeconds($seconds)
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $l = $reader.ReadLine()
+ if ($null -ne $l) { Say "[crier-sc] <- $l" }
+ } catch { Start-Sleep -Milliseconds 50 }
+ }
+}
+
+function Send($obj) {
+ $writer.WriteLine($obj)
+ Say "[crier-sc] -> $obj"
+ Drain 1.5
+}
+
+# 1. valid add
+Send '{"kind":"towncrier.add","id":"n1","lines":["Hear ye, hear ye!","The market tax is now 5 percent."],"durationSec":3600}'
+# 2. add exceeding the line cap (default 6) -> error
+Send '{"kind":"towncrier.add","id":"n2","lines":["1","2","3","4","5","6","7","8"],"durationSec":60}'
+# 3. remove the valid one
+Send '{"kind":"towncrier.remove","id":"n1"}'
+# 4. remove an unknown id -> error
+Send '{"kind":"towncrier.remove","id":"does-not-exist"}'
+
+Drain 3
+
+Say "[crier-sc] done"
+$client.Close(); $listener.Stop()