feat(admin): forward in-game moderation to the website (bidirectional audit)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-13 02:12:30 -05:00
parent 5968007882
commit 0902797ff5
4 changed files with 255 additions and 0 deletions

View File

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