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 be05ea912c
commit b8058094f0
9 changed files with 351 additions and 0 deletions

View File

@@ -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** | | 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** | | 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** | | 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 | | 6 — town-crier inbound | not started |
| 7 — `PlayerVendorSale` core event | not started | | 7 — `PlayerVendorSale` core event | not started |
| 8 — cheat signals | 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. | | `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. | | `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. | | `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. `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.

View File

@@ -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. 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. 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. 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. 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. 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. 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 ## 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`. `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`.

View File

@@ -22,6 +22,13 @@ EconomySweepSeconds=300
# Shown to a player when they run [link. The website page where they enter the code. # Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link 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 # The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed # Config.Get returns the default of false when a key is missing, so a deployed

View File

@@ -20,6 +20,11 @@ namespace Server.Custom.Bridge
public static string LinkUrl { get; private set; } 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 bool Enabled { get; private set; }
public static void Configure() public static void Configure()
@@ -42,6 +47,11 @@ namespace Server.Custom.Bridge
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); 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) if (QueueCap < 16)
QueueCap = 16; QueueCap = 16;
} }

View File

@@ -149,6 +149,35 @@ namespace Server.Custom.Bridge
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture); 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) public static int GetInt(Dictionary<string, object> o, string key, int fallback)
{ {
object v; 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);
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Text;
using Server.Mobiles;
namespace Server.Custom
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}

View File

@@ -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`. | | `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. | | `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`. | | `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 ## Deploy overwrites Bridge.cfg

View File

@@ -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()