From e4b62ff5ce9873b1ee8e0f2649e9e9865abc256e Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:56:22 -0500 Subject: [PATCH] Phase 6: town-crier news (website -> game) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- overlay/Config/Bridge.cfg | 7 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 10 ++ overlay/Scripts/Custom/Bridge/BridgeJson.cs | 29 ++++ .../Scripts/Custom/Bridge/BridgeTownCrier.cs | 158 ++++++++++++++++++ tools/scaffolding/BridgeCrierProbe.cs | 58 +++++++ tools/scaffolding/README.md | 1 + tools/stub_sidecar_crier.ps1 | 64 +++++++ 7 files changed, 327 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs create mode 100644 tools/scaffolding/BridgeCrierProbe.cs create mode 100644 tools/stub_sidecar_crier.ps1 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()