namespace Aeshnidae.MoveGuard;
/// What one rejected or reported position looked like, for the log and the audit line.
internal sealed record Violation(string Kind, double Distance, double Allowed, double Elapsed, double Ceiling, string Where);
/// Per-player bookkeeping. One per online character, dropped at logout.
internal sealed class PlayerState
{
///
/// When the server last took a position from this client. The allowance for the
/// next move is measured from here, not from the previous packet, so a rejected
/// position does not advance the clock.
///
public DateTime LastAccepted;
///
/// Strike times, oldest first. Mutated on the landblock thread and read by
/// /moveguard on a command thread, so every touch takes -
/// a Queue is not safe to enumerate while another thread dequeues.
///
public readonly Queue Strikes = new();
public readonly object Gate = new();
public long SpeedViolations, GeometryViolations, Rejections;
public Violation? Last;
/// Strike count at the last audit post, so posts go out per threshold rather than per strike.
public int StrikesAtLastAudit;
public bool Kicked;
/// Rejection times, a subset of Strikes. Only these count toward a kick.
public readonly Queue RejectionTimes = new();
/// Records a strike and returns how many fall inside the window.
public int AddStrike(DateTime now, int windowSeconds)
{
lock (Gate)
{
Strikes.Enqueue(now);
return Prune(Strikes, now, windowSeconds);
}
}
/// Records a rejection and returns how many fall inside the window.
public int AddRejection(DateTime now, int windowSeconds)
{
lock (Gate)
{
RejectionTimes.Enqueue(now);
return Prune(RejectionTimes, now, windowSeconds);
}
}
/// Strikes inside the window, for readers on other threads (/moveguard).
public int CountInWindow(DateTime now, int windowSeconds)
{
lock (Gate)
{
var cutoff = now.AddSeconds(-windowSeconds);
return Strikes.Count(t => t >= cutoff);
}
}
private static int Prune(Queue q, DateTime now, int windowSeconds)
{
var cutoff = now.AddSeconds(-windowSeconds);
while (q.Count > 0 && q.Peek() < cutoff)
q.Dequeue();
return q.Count;
}
}
internal static class Tracker
{
///
/// player guid -> state. Each player's entry is touched from that player's
/// landblock thread and read by /moveguard from a command thread, hence the
/// concurrent map; the state inside is only ever mutated from the one thread.
///
private static readonly ConcurrentDictionary _states = new();
public static PlayerState For(Player player) =>
_states.GetOrAdd(player.Guid.Full, _ => new PlayerState());
public static PlayerState? Peek(Player player) =>
_states.TryGetValue(player.Guid.Full, out var s) ? s : null;
public static void Forget(Player player) =>
_states.TryRemove(player.Guid.Full, out _);
public static void Clear() => _states.Clear();
public static int Count => _states.Count;
/// Every tracked player with at least one strike ever, worst first.
public static IEnumerable<(uint Guid, PlayerState State)> Offenders() =>
_states.Where(kv => kv.Value.SpeedViolations + kv.Value.GeometryViolations > 0)
.OrderByDescending(kv => kv.Value.SpeedViolations + kv.Value.GeometryViolations)
.Select(kv => (kv.Key, kv.Value));
}