namespace Aeshnidae.MoveGuard;
///
/// Admin-only. Everything here is about other players' movement, which is not a
/// thing players should be able to read about each other.
///
public static class Commands
{
[CommandHandler("moveguard", AccessLevel.Admin, CommandHandlerFlag.None, 0,
"Movement validation: status, mode, per-player detail.",
"/moveguard [ mode off|log|enforce | check | me | top | reload ]")]
public static void HandleMoveGuard(Session session, params string[] parameters)
{
try
{
var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : "status";
switch (verb)
{
case "mode" when parameters.Length > 1: SetMode(session, parameters[1]); return;
case "check" when parameters.Length > 1: Check(session, parameters[1]); return;
case "me": Check(session, session.Player?.Name ?? ""); return;
case "top": Top(session); return;
case "reload": Mod.Reload(); Status(session); return;
default: Status(session); return;
}
}
catch (Exception ex)
{
Reply(session, $"/moveguard failed: {ex.Message}");
ModManager.Log($"[{Mod.Name}] /moveguard failed: {ex}", ModManager.LogLevel.Error);
}
}
private static void Status(Session? session)
{
var s = Mod.Settings;
var text = new StringBuilder()
.AppendLine($"{Mod.Name} v{Mod.Container?.Meta.Version ?? "?"} - {Mod.Container?.Status.ToString() ?? "not loaded"}")
.AppendLine($"Mode: {s.ModeValue} (exempt at {s.ExemptLevel}+)")
.AppendLine($"Speed: top speed x{s.SpeedTolerance:F2} over up to {s.MaxCatchUpSeconds:F1}s, plus {s.SlackUnits:F1} units; {s.TeleportGraceSeconds:F0}s grace after a teleport")
.AppendLine($"Geometry: {(s.CheckGeometry ? $"checked, tolerance {s.GeometryTolerance:F1} units, {(s.EnforceGeometry ? "enforced" : "log only")}" : "off")}")
.AppendLine($"Strikes: audit at {s.AuditAfterStrikes} in {s.StrikeWindowSeconds}s, kick at {(s.KickAfterStrikes > 0 ? s.KickAfterStrikes.ToString() : "never")}")
.AppendLine($"Since start - speed {Mod.SpeedViolations}, geometry {Mod.GeometryViolations}, rejected {Mod.Rejections}, kicked {Mod.Kicks}; tracking {Tracker.Count} online")
.ToString();
Reply(session, text);
}
private static void SetMode(Session? session, string value)
{
if (!Enum.TryParse(value, true, out var mode))
{
Reply(session, "Mode is off, log or enforce.");
return;
}
Mod.Settings.Mode = mode.ToString();
Mod.Settings.Save(Mod.ModPath);
var who = session?.Player?.Name ?? "console";
ModManager.Log($"[{Mod.Name}] mode set to {mode} by {who}");
PlayerManager.BroadcastToAuditChannel(session?.Player, $"[MoveGuard] {who} set movement validation to {mode}");
Reply(session, $"Mode is now {mode}.");
}
/// One player: their computed ceiling and everything recorded against them.
private static void Check(Session? session, string name)
{
var player = PlayerManager.GetOnlinePlayer(name);
if (player is null)
{
Reply(session, $"{name} is not online. (History is kept only while a player is online.)");
return;
}
var runSkill = player.GetCreatureSkill(Skill.Run)?.Current ?? 0;
var ceiling = Speed.CeilingFor(player);
var allowance = ceiling * Mod.Settings.SpeedTolerance * Mod.Settings.MaxCatchUpSeconds + Mod.Settings.SlackUnits;
var text = new StringBuilder()
.AppendLine($"{player.Name}: Run {runSkill}, top speed {ceiling:F2} units/s, longest single move allowed {allowance:F1} units")
.AppendLine($" at {player.Location?.ToLOCString() ?? "?"}{(Mod.Settings.IsExempt(player) ? " (staff - never rejected)" : "")}");
var state = Tracker.Peek(player);
if (state is null || state.SpeedViolations + state.GeometryViolations == 0)
{
text.AppendLine(" no strikes this session");
}
else
{
text.AppendLine($" strikes: speed {state.SpeedViolations}, geometry {state.GeometryViolations}, rejected {state.Rejections}, " +
$"{state.CountInWindow(DateTime.UtcNow, Mod.Settings.StrikeWindowSeconds)} in the last {Mod.Settings.StrikeWindowSeconds}s");
if (state.Last is { } v)
text.AppendLine($" latest: {Guard.Describe(v)}");
}
Reply(session, text.ToString());
}
/// Everyone online with a strike, worst first.
private static void Top(Session? session)
{
var rows = Tracker.Offenders().Take(15).ToList();
if (rows.Count == 0)
{
Reply(session, "No strikes against anyone currently online.");
return;
}
var text = new StringBuilder("Strikes this session, worst first:\n");
foreach (var (guid, state) in rows)
{
var name = PlayerManager.GetOnlinePlayer(guid)?.Name ?? $"0x{guid:X8}";
text.AppendLine($" {name,-24} speed {state.SpeedViolations,4} geometry {state.GeometryViolations,4} rejected {state.Rejections,4}");
}
Reply(session, text.ToString());
}
///
/// The client renders an embedded newline as a music note, so a multi-line
/// message goes out as one SendMessage per line.
///
private static void Reply(Session? session, string message)
{
if (session?.Player is null)
{
ModManager.Log(message);
return;
}
foreach (var line in (message ?? "").Split('\n'))
{
var text = line.TrimEnd('\r');
if (!string.IsNullOrWhiteSpace(text))
session.Player.SendMessage(text);
}
}
}