From b8058094f0e0910b939cf63f1ec2db1db1e6dec5 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 --- README.md | 2 + docs/PLAN.md | 22 +++ 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 +++++++ 9 files changed, 351 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/README.md b/README.md index 67d4b4e..9fee76d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree | 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** | | 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** | | 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** | +| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** | | 6 — town-crier inbound | not started | | 7 — `PlayerVendorSale` core event | not started | | 8 — cheat signals | not started | @@ -70,6 +71,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S | `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. | | `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. | | `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. | +| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. | `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. diff --git a/docs/PLAN.md b/docs/PLAN.md index e1c6c65..e886427 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -308,6 +308,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. +6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -349,6 +350,27 @@ Two defects were found this way and fixed: --- +## 16. Phase 6 acceptance + +`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary. + +Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree: + +| Sent | Reply | Crier list | +|------|-------|------------| +| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines | +| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list | +| `remove n1` | `towncrier.ok` | entry gone | +| `remove does-not-exist` | `towncrier.error "unknown id"` | no change | + +The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged. + +Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs. + +No core changes; this closes the pure-plugin inbound work. + +--- + ## 15. Phase 5 acceptance `BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`. 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()