namespace Aeshnidae.MoveGuard; public enum Mode { /// Patched but inert - nothing is measured. Off, /// Measure and report. Nothing is rejected. Start here. Log, /// Reject and rubber-band. Geometry rejections need EnforceGeometry too. Enforce, } /// /// Settings.json in the deployed mod folder. Edit and /moveguard reload. /// public class Settings { public const string FileName = "Settings.json"; /// Off, Log or Enforce. Log is the default on purpose - see the Readme. public string Mode { get; set; } = "Log"; // ------------------------------------------------------------------- speed /// /// How far over the player's real top speed a move may be before it counts. /// /// The ceiling is computed per player from their Run skill and the run animation, /// so this only has to absorb what that formula does not: jump-running (a running /// jump carries you faster than running), sliding down slopes, and rounding. /// 1.5 is generous for all of those and still a tenth of what a blink covers. /// public double SpeedTolerance { get; set; } = 1.5; /// /// Units added to every allowance regardless of elapsed time, so a tiny move /// after a tiny interval is never a rounding-error violation. /// public double SlackUnits { get; set; } = 3.0; /// /// The longest gap a single position update may account for, in seconds. /// /// Position updates arrive about once a second while moving, but lag bunches /// them: after a two-second stall the next update legitimately covers two seconds /// of running. This caps how much a gap is worth. A player standing still for a /// minute then blinking is allowed exactly this many seconds of running distance - /// so the number is the length of the longest blink that gets through, in seconds /// of running. Lower is stricter and rubber-bands real players during longer lag /// spikes; the default trades that against a ~60-unit blink from standstill. /// public double MaxCatchUpSeconds { get; set; } = 2.5; /// /// Seconds after a teleport (portal, recall, lifestone, admin) during which nothing /// is judged. The client is still sending positions from where it was, and ACE /// itself notes this as a known quirk. /// public double TeleportGraceSeconds { get; set; } = 3.0; /// /// Run-animation speed in units per second, used only if the player's motion /// table cannot be read. Human run animation is a little over 4. /// public double FallbackRunSpeed { get; set; } = 4.5; // ---------------------------------------------------------------- geometry /// /// Also ask the physics engine whether the requested position is reachable from /// the current one - the same collision transition ACE already runs and then /// ignores. This is what sees a short blink through a wall, which is too short /// for the speed check to notice. /// public bool CheckGeometry { get; set; } = true; /// /// How far, in units, the physics-resolved end position may fall short of the /// requested one before it counts. Physics slides you along a wall rather than /// failing, so a wall in the way shows up as a large shortfall; ordinary /// disagreement between client and server physics is well under a unit. /// public double GeometryTolerance { get; set; } = 2.0; /// /// Reject geometry violations too, not only speed. OFF by default because /// server physics does disagree with the client in a handful of honest cases - /// doors that closed during lag, jumps across cell boundaries, a monster the /// server thinks is in the way - and rubber-banding an honest player is worse /// than logging a cheat. Read the log for a while first. /// public bool EnforceGeometry { get; set; } = false; // ----------------------------------------------------------------- strikes /// Strikes older than this no longer count toward the thresholds below. public int StrikeWindowSeconds { get; set; } = 60; /// /// Server-log lines per player per window. Strikes past this are still counted, /// audited and enforced - only the individual log line is dropped, so a false /// positive that hits everyone cannot flood the journal. /// public int LogPerWindow { get; set; } = 5; /// /// Post to the audit channel once a player has this many strikes in the window, /// and again for every further multiple. One rejected position is lag; three in a /// minute is a pattern worth a staff member's eyes. /// public int AuditAfterStrikes { get; set; } = 3; /// /// Disconnect a player at this many REJECTED positions in the window - logged-only /// strikes never count toward this. 0 disables. Enforce /// mode already makes cheating useless by holding the player where the server /// says they are; this is for when you would rather they were not online at all. /// public int KickAfterStrikes { get; set; } = 0; /// Tell the player when a position is rejected. Off: a silent snap back. public bool TellPlayer { get; set; } = false; public string PlayerMessage { get; set; } = "The server could not accept that movement."; /// /// Staff at or above this level are logged like anyone else but never /// rubber-banded or kicked. Admin, Developer, Envoy, Sentinel, Advocate, Player. /// public string ExemptAtOrAbove { get; set; } = "Admin"; // ----------------------------------------------------------------- helpers public Mode ModeValue => Enum.TryParse(Mode, true, out var m) ? m : MoveGuard.Mode.Log; public AccessLevel ExemptLevel => Enum.TryParse(ExemptAtOrAbove, true, out var lvl) ? lvl : AccessLevel.Admin; public bool IsExempt(Player? player) => player?.Session is { } s && s.AccessLevel >= ExemptLevel; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); var defaults = new Settings(); defaults.Save(modPath); ModManager.Log($"[{Mod.Name}] wrote default settings to {path}"); return defaults; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn); return new Settings(); } } public void Save(string modPath) { try { File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error); } } }