diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index d85df63..949273c 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -19,6 +19,11 @@ StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
+# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
+# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
+# support queue; the full open queue is also available on demand via pages.snapshot.
+PageSweepSeconds=5
+
# Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link
@@ -29,6 +34,24 @@ TownCrierMaxLineLength=200
TownCrierMaxActive=20
TownCrierMaxDurationSec=86400
+# Admin write plane (staff moderation from the website). OFF by default: the whole
+# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
+# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
+# loopback socket and applies a hard floor below.
+AdminWriteEnabled=false
+
+# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
+# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
+# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
+# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
+AdminAccessFloor=CoOwner
+
+# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
+AdminBroadcastMaxLength=300
+AdminReasonMaxLength=400
+# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
+AdminBanMaxDurationSec=31536000
+
# 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/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
new file mode 100644
index 0000000..1171713
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
@@ -0,0 +1,362 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Network;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// The staff write plane: moderation actions the website drives against the live shard.
+ /// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
+ ///
+ /// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
+ /// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
+ ///
+ /// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
+ /// these commands are gated there behind admin/moderator roles. The shard trusts the
+ /// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
+ /// CoOwner authority. Its one hard floor is : a command refuses any
+ /// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
+ /// buggy sidecar can never ban, kick, or otherwise touch the Owner.
+ ///
+ /// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
+ /// Attribution rides on a required "actor" field (the website staff user); every applied
+ /// action logs to the console and emits an admin.audit event the website persists.
+ ///
+ public static class BridgeAdmin
+ {
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ BridgeBoot.RegisterHandler("admin.kick", OnKick);
+ BridgeBoot.RegisterHandler("admin.ban", OnBan);
+ BridgeBoot.RegisterHandler("admin.unban", OnUnban);
+ BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
+ }
+
+ // ---- admin.kick ----
+
+ /// Disconnects every live session of the target account. Target by serial or account.
+ private static void OnKick(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var actor = BridgeJson.GetString(o, "actor");
+ const string action = "kick";
+
+ if (!Ready(reqId, action, actor))
+ return;
+
+ var acct = ResolveTargetAccount(o);
+ if (acct == null)
+ {
+ Err(reqId, action, "unknown or accountless target");
+ return;
+ }
+
+ if (Protected(acct))
+ {
+ Err(reqId, action, "target is protected staff; refused");
+ return;
+ }
+
+ int kicked = KickAccountSessions(acct);
+ var reason = Reason(o);
+
+ Log(actor, action, acct.Username, reason);
+ BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
+ .Num("sessions", kicked)
+ .Str("reason", reason)
+ .End());
+
+ var sb = BridgeJson.Begin("admin.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- admin.ban ----
+
+ ///
+ /// Bans an account (offline-capable) and disconnects any live sessions. A positive
+ /// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
+ /// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
+ ///
+ private static void OnBan(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var actor = BridgeJson.GetString(o, "actor");
+ const string action = "ban";
+
+ if (!Ready(reqId, action, actor))
+ return;
+
+ var acct = ResolveTargetAccount(o);
+ if (acct == null)
+ {
+ Err(reqId, action, "unknown or accountless target");
+ return;
+ }
+
+ if (Protected(acct))
+ {
+ Err(reqId, action, "target is protected staff; refused");
+ return;
+ }
+
+ int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
+ if (durationSec < 0)
+ durationSec = 0;
+ if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
+ durationSec = BridgeConfig.AdminBanMaxDurationSec;
+
+ if (durationSec > 0)
+ acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
+ else
+ acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
+
+ // SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
+ acct.SetTag("BanDealer", WebActor(actor));
+ acct.Banned = true;
+
+ int kicked = KickAccountSessions(acct);
+ var reason = Reason(o);
+
+ Log(actor, action, acct.Username, reason);
+ BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
+ .Num("durationSec", durationSec)
+ .Num("sessions", kicked)
+ .Str("reason", reason)
+ .End());
+
+ var sb = BridgeJson.Begin("admin.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- admin.unban ----
+
+ private static void OnUnban(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var actor = BridgeJson.GetString(o, "actor");
+ const string action = "unban";
+
+ if (!Ready(reqId, action, actor))
+ return;
+
+ var acct = ResolveTargetAccount(o);
+ if (acct == null)
+ {
+ Err(reqId, action, "unknown or accountless target");
+ return;
+ }
+
+ acct.Banned = false;
+ acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
+
+ var reason = Reason(o);
+
+ Log(actor, action, acct.Username, reason);
+ BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
+ .Str("reason", reason)
+ .End());
+
+ Ok(reqId, action, acct.Username);
+ }
+
+ // ---- admin.broadcast ----
+
+ private static void OnBroadcast(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var actor = BridgeJson.GetString(o, "actor");
+ const string action = "broadcast";
+
+ if (!Ready(reqId, action, actor))
+ return;
+
+ var text = BridgeJson.GetString(o, "text");
+ if (String.IsNullOrEmpty(text))
+ {
+ Err(reqId, action, "missing text");
+ return;
+ }
+
+ if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
+ text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
+
+ // Default to the staff-broadcast green; callers may override.
+ int hue = BridgeJson.GetInt(o, "hue", 0x35);
+
+ World.Broadcast(hue, false, text);
+
+ Log(actor, action, null, text);
+ BridgeLink.Emit(AuditBegin(action, actor, null)
+ .Num("hue", hue)
+ .Str("text", text)
+ .End());
+
+ var sb = BridgeJson.Begin("admin.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- shared prologue / replies ----
+
+ /// Common gate: the write plane must be enabled and an actor must be present.
+ private static bool Ready(string reqId, string action, string actor)
+ {
+ if (!BridgeConfig.AdminWriteEnabled)
+ {
+ Err(reqId, action, "admin write plane disabled");
+ return false;
+ }
+
+ if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
+ {
+ Err(reqId, action, "missing actor");
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void Ok(string reqId, string action, string target)
+ {
+ var sb = BridgeJson.Begin("admin.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action);
+ if (target != null) sb.Str("target", target);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void Err(string reqId, string action, string reason)
+ {
+ var sb = BridgeJson.Begin("admin.error");
+ if (reqId != null) sb.Str("reqId", reqId);
+ if (action != null) sb.Str("action", action);
+ sb.Str("reason", reason);
+ BridgeLink.Emit(sb.End());
+ }
+
+ ///
+ /// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
+ /// connected dashboard so the website's moderation log stays complete regardless of which
+ /// client issued the action. The in-game counterpart (origin=in-game) is emitted from
+ /// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
+ ///
+ private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
+ {
+ return BridgeJson.Begin("admin.audit")
+ .Str("origin", "web")
+ .Str("action", action)
+ .Str("actor", WebActor(actor))
+ .Str("target", target);
+ }
+
+ private static string WebActor(string actor)
+ {
+ return "web:" + actor;
+ }
+
+ /// Reads and length-clamps the optional reason string.
+ private static string Reason(Dictionary o)
+ {
+ var reason = BridgeJson.GetString(o, "reason");
+ if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
+ reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
+ return reason;
+ }
+
+ private static void Log(string actor, string action, string target, string detail)
+ {
+ Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
+ WebActor(actor), action, target ?? "-", detail ?? "-");
+ }
+
+ // ---- target resolution & floor ----
+
+ ///
+ /// Resolves the command's target account, by "serial" (a player mobile's account) or by
+ /// "account" (username). Returns null if neither resolves to a real account.
+ ///
+ private static Account ResolveTargetAccount(Dictionary o)
+ {
+ var serialStr = BridgeJson.GetString(o, "serial");
+ if (serialStr != null)
+ {
+ var m = ResolveSerial(serialStr);
+ return m == null ? null : m.Account as Account;
+ }
+
+ var acctName = BridgeJson.GetString(o, "account");
+ return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
+ }
+
+ ///
+ /// The one shard-side safety floor. Protects any account whose effective access level —
+ /// the account's own or the highest of its characters' — is at or above the configured
+ /// floor. Even under CoOwner authority the Owner is never reachable from the web.
+ ///
+ private static bool Protected(Account acct)
+ {
+ var lvl = acct.AccessLevel;
+
+ for (int i = 0; i < acct.Length; i++)
+ {
+ var m = acct[i];
+ if (m != null && m.AccessLevel > lvl)
+ lvl = m.AccessLevel;
+ }
+
+ return lvl >= BridgeConfig.AdminAccessFloor;
+ }
+
+ ///
+ /// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
+ /// (rather than walking the account's characters) also catches a session parked at
+ /// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
+ /// mutates the instance set.
+ ///
+ private static int KickAccountSessions(Account acct)
+ {
+ var doomed = new List();
+
+ foreach (var ns in NetState.Instances)
+ {
+ if (ns != null && ns.Account == acct)
+ doomed.Add(ns);
+ }
+
+ foreach (var ns in doomed)
+ ns.Dispose();
+
+ return doomed.Count;
+ }
+
+ private static Mobile ResolveSerial(string serialStr)
+ {
+ try
+ {
+ var s = serialStr.Trim();
+ int value;
+
+ if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ value = Convert.ToInt32(s.Substring(2), 16);
+ else
+ value = Convert.ToInt32(s, 10);
+
+ return World.FindMobile(value);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index b6fda4e..4456653 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -159,6 +159,7 @@ namespace Server.Custom.Bridge
case "reload":
BridgeConfig.Load();
BridgeSweeps.Rearm();
+ BridgePages.Rearm();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break;
@@ -181,6 +182,7 @@ namespace Server.Custom.Bridge
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
break;
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 5d39760..480ed47 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -17,6 +17,7 @@ namespace Server.Custom.Bridge
public static int StatSweepSeconds { get; private set; }
public static int DecaySweepSeconds { get; private set; }
public static int EconomySweepSeconds { get; private set; }
+ public static int PageSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -25,6 +26,12 @@ namespace Server.Custom.Bridge
public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { get; private set; }
+ public static bool AdminWriteEnabled { get; private set; }
+ public static AccessLevel AdminAccessFloor { get; private set; }
+ public static int AdminBroadcastMaxLength { get; private set; }
+ public static int AdminReasonMaxLength { get; private set; }
+ public static int AdminBanMaxDurationSec { get; private set; }
+
public static bool Enabled { get; private set; }
public static void Configure()
@@ -44,6 +51,9 @@ namespace Server.Custom.Bridge
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
+ PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
+ if (PageSweepSeconds < 1)
+ PageSweepSeconds = 1;
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
@@ -52,15 +62,37 @@ namespace Server.Custom.Bridge
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
+ AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
+ AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
+ AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
+ AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
+ AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
+
if (QueueCap < 16)
QueueCap = 16;
}
+ ///
+ /// Parses an AccessLevel name from config, case-insensitively, falling back to the given
+ /// default on anything unrecognized so a typo can never open the floor wider than intended.
+ ///
+ private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
+ {
+ AccessLevel parsed;
+ if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
+ Enum.IsDefined(typeof(AccessLevel), parsed))
+ return parsed;
+
+ Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
+ return fallback;
+ }
+
public static string Describe()
{
return String.Format(
- "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
- Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds);
+ "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
+ Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
+ AdminWriteEnabled, AdminAccessFloor);
}
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgePages.cs b/overlay/Scripts/Custom/Bridge/BridgePages.cs
new file mode 100644
index 0000000..ff3a541
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgePages.cs
@@ -0,0 +1,420 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+using Server.Accounting;
+using Server.Engines.Help;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// The in-game help-page (support ticket) queue, surfaced to the website.
+ ///
+ /// A player who uses the Help button creates a — sender, message,
+ /// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
+ /// no EventSink, so — like the sweeps in — it is polled and diffed:
+ /// a page appearing emits page.new, one leaving emits page.closed, and a
+ /// handled-state change emits page.updated. The whole open queue is also available on
+ /// demand via the pages.snapshot request (the backfill a dashboard uses on connect).
+ ///
+ /// A page is keyed by its sender's serial: the queue enforces one page per sender
+ /// (PageQueue.Contains), so the sender serial is a stable page id.
+ ///
+ /// Inbound page.respond delivers a message to the player exactly as an in-game staff
+ /// response does (online: a gump now; offline: queued for next login), optionally closing the
+ /// page; page.close just removes it. Both run on the Core thread.
+ ///
+ public static class BridgePages
+ {
+ private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private static Timer _timer;
+ private static long _sweeps, _new, _closed, _updated;
+
+ private struct Seen
+ {
+ public long SentMs;
+ public bool Handled;
+ }
+
+ // sender serial -> last-seen page identity. Core-thread only.
+ private static readonly Dictionary _seen = new Dictionary();
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
+ BridgeBoot.RegisterHandler("page.respond", OnRespond);
+ BridgeBoot.RegisterHandler("page.close", OnClose);
+
+ EventSink.ServerStarted += OnServerStarted;
+ }
+
+ private static void OnServerStarted()
+ {
+ Baseline();
+ Rearm();
+ }
+
+ /// Stops and recreates the poll timer from current config. Called by `[bridge reload`.
+ public static void Rearm()
+ {
+ if (_timer != null)
+ {
+ _timer.Stop();
+ _timer = null;
+ }
+
+ var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
+ _timer = Timer.DelayCall(iv, iv, Sweep);
+ }
+
+ public static string Status()
+ {
+ return String.Format(
+ "pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
+ _sweeps, _new, _closed, _updated, _seen.Count);
+ }
+
+ /// Seeds _seen from the current queue without emitting, so a restart/reload does not
+ /// re-announce pages already open.
+ private static void Baseline()
+ {
+ _seen.Clear();
+
+ foreach (PageEntry e in PageQueue.List)
+ {
+ if (e == null || e.Sender == null)
+ continue;
+
+ _seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
+ }
+ }
+
+ // ---- poll ----
+
+ private static void Sweep()
+ {
+ try
+ {
+ _sweeps++;
+
+ var cur = new Dictionary();
+
+ foreach (PageEntry e in PageQueue.List)
+ {
+ if (e == null || e.Sender == null)
+ continue;
+
+ cur[e.Sender.Serial.Value] = e;
+ }
+
+ // Closed: keys in _seen no longer present.
+ if (_seen.Count > 0)
+ {
+ List gone = null;
+
+ foreach (var kv in _seen)
+ {
+ if (!cur.ContainsKey(kv.Key))
+ {
+ if (gone == null)
+ gone = new List();
+ gone.Add(kv.Key);
+ }
+ }
+
+ if (gone != null)
+ {
+ foreach (var id in gone)
+ {
+ EmitClosed(id);
+ _seen.Remove(id);
+ }
+ }
+ }
+
+ // New / replaced / handled-state changed.
+ foreach (var kv in cur)
+ {
+ var e = kv.Value;
+ long sentMs = ToMs(e.Sent);
+ bool handled = e.Handler != null;
+
+ Seen prev;
+ if (!_seen.TryGetValue(kv.Key, out prev))
+ {
+ EmitNew(e);
+ }
+ else if (prev.SentMs != sentMs)
+ {
+ // Same sender, different page (they cancelled and re-paged within a tick).
+ EmitClosed(kv.Key);
+ EmitNew(e);
+ }
+ else if (prev.Handled != handled)
+ {
+ EmitUpdated(e);
+ }
+
+ _seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
+ }
+ }
+
+ // ---- outbound ----
+
+ private static void EmitNew(PageEntry e)
+ {
+ _new++;
+ var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
+ AppendPageTail(sb, e);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void EmitUpdated(PageEntry e)
+ {
+ _updated++;
+ var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
+ AppendPageTail(sb, e);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void EmitClosed(int serial)
+ {
+ _closed++;
+ BridgeLink.Emit(BridgeJson.Begin("page.closed")
+ .Str("pageId", "0x" + serial.ToString("X"))
+ .End());
+ }
+
+ private static void OnSnapshot(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+
+ var sb = BridgeJson.Begin("pages.list");
+ if (reqId != null)
+ sb.Str("reqId", reqId);
+
+ sb.Append(",\"pages\":[");
+
+ bool first = true;
+ foreach (PageEntry e in PageQueue.List)
+ {
+ if (e == null || e.Sender == null)
+ continue;
+
+ if (!first)
+ sb.Append(',');
+ first = false;
+
+ sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
+ AppendPageTail(sb, e);
+ sb.Append('}');
+ }
+
+ sb.Append(']');
+ BridgeLink.Emit(sb.End());
+ }
+
+ /// Appends every page field except the opening pageId, each comma-prefixed, so it
+ /// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).
+ private static void AppendPageTail(StringBuilder sb, PageEntry e)
+ {
+ sb.Append(",\"sender\":");
+ WriteSender(sb, e.Sender);
+
+ sb.Str("type", e.Type.ToString());
+ sb.Str("message", e.Message ?? "");
+ sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
+ sb.Num("x", e.PageLocation.X);
+ sb.Num("y", e.PageLocation.Y);
+ sb.Num("z", e.PageLocation.Z);
+ sb.Num("sentMs", ToMs(e.Sent));
+ sb.Bool("handled", e.Handler != null);
+
+ if (e.Handler != null)
+ sb.Str("handler", e.Handler.Name);
+ }
+
+ private static void WriteSender(StringBuilder sb, Mobile m)
+ {
+ if (m == null)
+ {
+ sb.Append("null");
+ return;
+ }
+
+ sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
+ sb.Append(",\"name\":");
+ BridgeJson.Escape(sb, m.Name ?? "");
+
+ var acct = m.Account as Account;
+ if (acct != null)
+ {
+ sb.Append(",\"acct\":");
+ BridgeJson.Escape(sb, acct.Username);
+
+ var webId = BridgeAccountLink.WebIdFor(acct);
+ if (webId != null)
+ {
+ sb.Append(",\"webId\":");
+ BridgeJson.Escape(sb, webId);
+ }
+ }
+
+ sb.Append('}');
+ }
+
+ // ---- inbound ----
+
+ /// page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
+ /// player and optionally closes the page.
+ private static void OnRespond(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var pageId = BridgeJson.GetString(o, "pageId");
+ var message = BridgeJson.GetString(o, "message");
+ bool close = GetBool(o, "close");
+
+ if (String.IsNullOrEmpty(message))
+ {
+ Err(reqId, "respond", pageId, "missing message");
+ return;
+ }
+
+ var e = Find(pageId);
+ if (e == null)
+ {
+ Err(reqId, "respond", pageId, "unknown page");
+ return;
+ }
+
+ try
+ {
+ // Same delivery as an in-game staff response: a null handler shows as "Staff".
+ // ResponseEntry queues for an offline sender; SendGump delivers now if online.
+ var re = new ResponseEntry(e.Sender, null, message);
+ re.SendGump();
+
+ if (close)
+ PageQueue.Remove(e);
+
+ Ok(reqId, "respond", pageId, close);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
+ Err(reqId, "respond", pageId, "internal error");
+ }
+ }
+
+ /// page.close {reqId, pageId}. Removes the page from the queue.
+ private static void OnClose(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+ var pageId = BridgeJson.GetString(o, "pageId");
+
+ var e = Find(pageId);
+ if (e == null)
+ {
+ Err(reqId, "close", pageId, "unknown page");
+ return;
+ }
+
+ try
+ {
+ PageQueue.Remove(e);
+ Ok(reqId, "close", pageId, true);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
+ Err(reqId, "close", pageId, "internal error");
+ }
+ }
+
+ private static void Ok(string reqId, string action, string pageId, bool closed)
+ {
+ var sb = BridgeJson.Begin("page.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action);
+ if (pageId != null) sb.Str("pageId", pageId);
+ sb.Bool("closed", closed);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void Err(string reqId, string action, string pageId, string reason)
+ {
+ var sb = BridgeJson.Begin("page.error");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action);
+ if (pageId != null) sb.Str("pageId", pageId);
+ sb.Str("reason", reason);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- helpers ----
+
+ private static PageEntry Find(string pageId)
+ {
+ int serial;
+ if (!TryParseSerial(pageId, out serial))
+ return null;
+
+ foreach (PageEntry e in PageQueue.List)
+ {
+ if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
+ return e;
+ }
+
+ return null;
+ }
+
+ private static string PageId(PageEntry e)
+ {
+ return "0x" + e.Sender.Serial.Value.ToString("X");
+ }
+
+ private static long ToMs(DateTime dt)
+ {
+ return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
+ }
+
+ private static bool GetBool(Dictionary o, string key)
+ {
+ object v;
+ if (o != null && o.TryGetValue(key, out v) && v is bool)
+ return (bool)v;
+ return false;
+ }
+
+ private static bool TryParseSerial(string s, out int value)
+ {
+ value = 0;
+ if (String.IsNullOrEmpty(s))
+ return false;
+
+ try
+ {
+ s = s.Trim();
+ if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ value = Convert.ToInt32(s.Substring(2), 16);
+ else
+ value = Convert.ToInt32(s, 10);
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/patches/BridgeModerationAudit.cs b/patches/BridgeModerationAudit.cs
new file mode 100644
index 0000000..2b5c686
--- /dev/null
+++ b/patches/BridgeModerationAudit.cs
@@ -0,0 +1,133 @@
+using System;
+
+using Server.Accounting;
+using Server.Commands;
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
+ /// (origin=in-game), so the site's moderation log is complete regardless of whether an action
+ /// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
+ ///
+ /// Two sources, mirroring how the shard records each:
+ /// - ban / kick: resolved with their target inside the stock generic command, which logs a
+ /// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
+ /// parse the "... banning|kicking <target> ('acct')" line for action and target.
+ /// - broadcast: [bcast carries its message as command args and hits no target, so
+ /// EventSink.Command already sees it whole; we reshape it.
+ ///
+ /// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
+ /// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
+ /// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
+ ///
+ /// Runs on the Core thread (both sources raise synchronously in the command path). Every body
+ /// is wrapped: a bridge exception must never escape into a staff command.
+ ///
+ public static class BridgeModerationAudit
+ {
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
+ EventSink.Command += OnStaffCommand; // broadcast (carries its message)
+
+ Console.WriteLine("[Bridge] in-game moderation audit attached");
+ }
+
+ ///
+ /// The stock ban/kick commands log "<level> <from> ('acct') banning|kicking
+ /// <target> ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
+ /// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
+ ///
+ private static void OnCommandLog(Mobile from, string text)
+ {
+ try
+ {
+ if (from == null || text == null)
+ return;
+
+ string action;
+ int at;
+
+ if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
+ action = "ban";
+ else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
+ action = "kick";
+ else
+ return;
+
+ var tail = text.Substring(at + 9); // past " banning " / " kicking "
+ Emit(action, from, ExtractAccount(tail), text);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
+ }
+ }
+
+ /// [bcast / [bc / [b — a staff broadcast. Its message is the command args.
+ private static void OnStaffCommand(CommandEventArgs e)
+ {
+ try
+ {
+ if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
+ return;
+
+ var cmd = e.Command;
+ if (cmd == null)
+ return;
+
+ cmd = cmd.ToLowerInvariant();
+ if (cmd != "bcast" && cmd != "bc" && cmd != "b")
+ return;
+
+ Emit("broadcast", e.Mobile, null, e.ArgString);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
+ }
+ }
+
+ /// Pulls the account from a CommandLogging.Format rendering's trailing "('account')".
+ private static string ExtractAccount(string formatted)
+ {
+ if (formatted == null)
+ return null;
+
+ int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
+ if (open < 0)
+ return null;
+
+ int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
+ if (close < 0)
+ return null;
+
+ return formatted.Substring(open + 2, close - (open + 2));
+ }
+
+ ///
+ /// Emits admin.audit with origin=in-game. The actor is the staff member's account name
+ /// (no "web:" prefix — that, plus the origin field, is how the website tells the two
+ /// sources apart). `detail` carries the raw context so nothing is lost if a target could
+ /// not be parsed.
+ ///
+ private static void Emit(string action, Mobile actor, string target, string detail)
+ {
+ var acct = actor.Account as Account;
+ var actorName = acct != null ? acct.Username : actor.Name;
+
+ BridgeLink.Emit(BridgeJson.Begin("admin.audit")
+ .Str("origin", "in-game")
+ .Str("action", action)
+ .Str("actor", actorName)
+ .Str("target", target)
+ .Str("detail", detail)
+ .End());
+ }
+ }
+}
diff --git a/patches/README.md b/patches/README.md
index c70f191..d59b8b0 100644
--- a/patches/README.md
+++ b/patches/README.md
@@ -32,6 +32,24 @@ Both patches are `git`-format and verified with `git apply --check` against stoc
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
+## In-game moderation audit (admin controls §5.5)
+
+So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
+
+| Item | Target | What |
+|------|--------|------|
+| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
+| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
+
+```bash
+cd
+git apply --check patches/commandlogging-event.patch # dry run
+git apply patches/commandlogging-event.patch
+cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
+```
+
+`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
+
## Note on `Scripts.csproj`
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
diff --git a/patches/commandlogging-event.patch b/patches/commandlogging-event.patch
new file mode 100644
index 0000000..37d5f3f
--- /dev/null
+++ b/patches/commandlogging-event.patch
@@ -0,0 +1,33 @@
+--- a/Scripts/Commands/Logging.cs
++++ b/Scripts/Commands/Logging.cs
+@@ -75,16 +75,27 @@
+ return o;
+ }
+
++ ///
++ /// Raised for every staff command log line — even when file logging is disabled — so an
++ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
++ /// forward moderation actions (ban/kick, with the resolved target) to the website.
++ ///
++ public static event Action OnWrite;
++
+ public static void WriteLine(Mobile from, string format, params object[] args)
+ {
+- if (!m_Enabled)
+- return;
+-
+ WriteLine(from, String.Format(format, args));
+ }
+
+ public static void WriteLine(Mobile from, string text)
+ {
++ var onWrite = OnWrite;
++ if (onWrite != null)
++ {
++ try { onWrite(from, text); }
++ catch { }
++ }
++
+ if (!m_Enabled)
+ return;
+
diff --git a/tools/scaffolding/BridgeAuditProbe.cs b/tools/scaffolding/BridgeAuditProbe.cs
new file mode 100644
index 0000000..de22467
--- /dev/null
+++ b/tools/scaffolding/BridgeAuditProbe.cs
@@ -0,0 +1,71 @@
+using System;
+
+using Server.Accounting;
+using Server.Commands;
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
+ /// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
+ /// end-to-end from a stub sidecar.
+ ///
+ /// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
+ /// EventSink.Command exactly as a staff keystroke would.
+ /// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
+ /// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
+ /// call that command makes at Commands.cs:1211, which is the point we tap.
+ /// - A non-moderation log line confirms the normalizer ignores everything else.
+ ///
+ /// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
+ /// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
+ ///
+ public static class BridgeAuditProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.AuditProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
+ }
+
+ private static void Run()
+ {
+ try
+ {
+ var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
+ var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
+
+ var from = staffAcct == null ? null : staffAcct[0];
+ var target = targetAcct == null ? null : targetAcct[0];
+
+ if (from == null || target == null)
+ {
+ Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
+ return;
+ }
+
+ Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
+ CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
+
+ Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
+ CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
+ from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
+
+ Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
+ CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
+ from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
+
+ Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
+ CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
+ from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
+
+ Console.WriteLine("[AuditProbe] done");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[AuditProbe] FAILED: " + ex);
+ }
+ }
+ }
+}
diff --git a/tools/scaffolding/BridgePageProbe.cs b/tools/scaffolding/BridgePageProbe.cs
new file mode 100644
index 0000000..3e518d5
--- /dev/null
+++ b/tools/scaffolding/BridgePageProbe.cs
@@ -0,0 +1,61 @@
+using System;
+
+using Server.Accounting;
+using Server.Engines.Help;
+
+namespace Server.Custom
+{
+ ///
+ /// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
+ /// (poll/stream + snapshot + respond/close) can be verified without a game client.
+ ///
+ /// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
+ /// each entry's InternalTimer would remove the page on its first tick because the sender has
+ /// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
+ /// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
+ /// respond, close — then operates on real queue entries.
+ ///
+ /// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
+ ///
+ public static class BridgePageProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.PageProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
+ }
+
+ private static void Run()
+ {
+ try
+ {
+ Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
+ Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
+ Console.WriteLine("[PageProbe] done");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[PageProbe] FAILED: " + ex);
+ }
+ }
+
+ private static void Enqueue(string account, string message, PageType type)
+ {
+ var acct = Accounting.Accounts.GetAccount(account) as Account;
+ var sender = acct == null ? null : acct[0];
+
+ if (sender == null)
+ {
+ Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
+ return;
+ }
+
+ var entry = new PageEntry(sender, message, type);
+ PageQueue.Enqueue(entry);
+ entry.Stop(); // keep it in the queue despite the offline sender
+
+ Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
+ type, account, sender.Serial.Value);
+ }
+ }
+}
diff --git a/tools/stub_sidecar_admin.ps1 b/tools/stub_sidecar_admin.ps1
new file mode 100644
index 0000000..3c02424
--- /dev/null
+++ b/tools/stub_sidecar_admin.ps1
@@ -0,0 +1,78 @@
+param(
+ [int] $Port = 7788,
+ [string] $Log = "$PSScriptRoot\sc_admin.log"
+)
+
+# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
+# fires admin.* commands covering the happy paths and every guard, logs the replies.
+# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
+
+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 "[admin] 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 "[admin] could not bind"; exit 1 }
+
+Say "[admin] listening"
+$client = $listener.AcceptTcpClient()
+Say "[admin] === shard connected ==="
+
+$stream = $client.GetStream()
+$reader = New-Object System.IO.StreamReader($stream)
+$writer = New-Object System.IO.StreamWriter($stream)
+$writer.AutoFlush = $true
+
+Start-Sleep -Milliseconds 500
+
+$requests = @(
+ # happy path, no target needed
+ '{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
+ # ban an offline seed account (timed), then unban
+ '{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
+ '{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
+ # kick an offline account -> should succeed with sessions:0
+ '{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
+ # floor: whitlocktech is Owner -> must be refused
+ '{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
+ # unknown target
+ '{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
+ # missing actor -> refused by the shared gate
+ '{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
+)
+
+foreach ($r in $requests) {
+ $writer.WriteLine($r)
+ Say "[admin] -> $r"
+ Start-Sleep -Milliseconds 400
+}
+
+# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
+# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
+$stream.ReadTimeout = 2500
+try {
+ while ($true) {
+ $line = $reader.ReadLine()
+ if ($null -eq $line) { break }
+ Say "[admin] <- $line"
+ }
+} catch {
+ Say "[admin] read window closed (idle)"
+}
+
+Say "[admin] done"
+$client.Close()
+$listener.Stop()