using System; using System.Collections.Generic; using Microsoft.Extensions.Logging; using VanadiumGuard.Protocol; using VanadiumGuard.Server.Sessions; namespace VanadiumGuard.Server.Policy { /// /// Turns detections into a verdict. This is the only place in the system where a /// decision about a player is made, and it runs on the server where the player cannot /// reach it. /// public sealed class PolicyEngine { /// Score at which the session is ended. public const int DisconnectThreshold = 80; /// Score at which the player is told something is wrong but keeps playing. public const int WarnThreshold = 40; private readonly ILogger _logger; public PolicyEngine(ILogger logger) { _logger = logger; } /// /// Records a batch against the session and returns what the client should do. /// public Verdict Evaluate(GuardSession session, IReadOnlyList detections) { bool decisive = false; foreach (Detection detection in detections) { DetectionCatalog.Entry entry = DetectionCatalog.For(detection.Code); if (entry.Weight <= 0) { _logger.LogDebug("Ignoring unweighted code {Code} from {Session}", detection.Code, session.SessionId); continue; } int confidence = Math.Clamp(detection.Confidence, 0, 100); // Confidence scales the weight. A detector rolled out at MaxConfidence 0 // contributes exactly nothing, which is what makes a safe rollout possible. double score = entry.Weight * (confidence / 100.0); session.Record(detection, score); // Decisive codes still have to arrive at high confidence. A weak reading of // a serious signal is evidence, not a conviction. if (entry.Decisive && confidence >= 85) { decisive = true; _logger.LogWarning( "Decisive detection {Code} subject={Subject} conf={Confidence} player={Player} session={Session}", detection.Code, detection.Subject, confidence, session.PlayerId, session.SessionId); } } return VerdictFor(session, decisive); } /// Re-derives a verdict without adding anything, for heartbeats and status. public Verdict Current(GuardSession session) { return VerdictFor(session, decisive: false); } private Verdict VerdictFor(GuardSession session, bool decisive) { // A forced verdict outranks the score. A hardware ban is not a risk judgement // and must not be re-derived from one. Verdict? forced = session.ForcedVerdict; if (forced != null) return forced; if (session.Terminated) { return new Verdict { Action = VerdictAction.Disconnect, ReasonCode = "VG-TERM", PlayerMessage = "This session was ended by an anti-cheat check.", }; } int score = session.RiskScore(); if (decisive || score >= DisconnectThreshold) { session.MarkTerminated(); _logger.LogWarning( "Disconnecting player={Player} session={Session} score={Score} decisive={Decisive}", session.PlayerId, session.SessionId, score, decisive); return new Verdict { Action = VerdictAction.Disconnect, ReasonCode = "VG-" + score.ToString("D3"), PlayerMessage = "You have been removed from this session by an anti-cheat check. " + "If you believe this is wrong, contact support and quote this code.", // A short grace period so the client can flush the batch that triggered // this. Without it the most important evidence is the evidence lost. DelayMs = 2000, }; } if (score >= WarnThreshold) { return new Verdict { Action = VerdictAction.Warn, ReasonCode = "VG-W" + score.ToString("D3"), PlayerMessage = "Close any debugging or memory tools before continuing. " + "Leaving them open may end your session.", }; } return new Verdict { Action = VerdictAction.Continue }; } /// /// Raises a detection the server itself observed, e.g. a bad signature or a guard /// that went quiet. These never come from the client, which is exactly why they are /// worth something. /// public void RecordServerSide(GuardSession session, DetectionCode code, string subject, int confidence, IDictionary? evidence = null) { var detection = new Detection { Code = code, Severity = Severity.High, Confidence = confidence, Subject = subject, Detector = "server", FirstObservedUtc = DateTimeOffset.UtcNow, LastObservedUtc = DateTimeOffset.UtcNow, }; if (evidence != null) { foreach (KeyValuePair pair in evidence) detection.Evidence[pair.Key] = pair.Value; } DetectionCatalog.Entry entry = DetectionCatalog.For(code); session.Record(detection, entry.Weight * (confidence / 100.0)); _logger.LogWarning( "Server-side detection {Code} subject={Subject} player={Player}", code, subject, session.PlayerId); } } }