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

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

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