From 2098e27b2d1677c424b410c605e746b652142e55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:22:10 +0000 Subject: [PATCH 01/13] feat: add cheater-to-player ratio analytics panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a moderation-support analytics sidebar panel that tracks flagged and confirmed cheater accounts against the active player count on a connected Rust server. Includes models, services, MVVM viewmodel, and WPF view. Data sources: Steam ISteamUser/GetPlayerBans public API (user-provided key), manual admin flags, and local JSON storage per server — no IP tracking, no hardware IDs, no cloud upload. Files added: - Models/CheaterRecord.cs - Models/CheaterAnalyticsSnapshot.cs - Services/SteamBanLookupService.cs - Services/CheaterAnalyticsService.cs - ViewModels/CheaterAnalyticsViewModel.cs - Views/CheaterAnalyticsPanel.xaml + .xaml.cs https://claude.ai/code/session_01J5coSH1ScT4g4yDSbwmmcZ --- .../Models/CheaterAnalyticsSnapshot.cs | 26 +++ RustPlusDesktop/Models/CheaterRecord.cs | 23 ++ .../Services/CheaterAnalyticsService.cs | 75 +++++++ .../Services/SteamBanLookupService.cs | 38 ++++ .../ViewModels/CheaterAnalyticsViewModel.cs | 183 ++++++++++++++++ .../Views/CheaterAnalyticsPanel.xaml | 204 ++++++++++++++++++ .../Views/CheaterAnalyticsPanel.xaml.cs | 12 ++ 7 files changed, 561 insertions(+) create mode 100644 RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs create mode 100644 RustPlusDesktop/Models/CheaterRecord.cs create mode 100644 RustPlusDesktop/Services/CheaterAnalyticsService.cs create mode 100644 RustPlusDesktop/Services/SteamBanLookupService.cs create mode 100644 RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs create mode 100644 RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml create mode 100644 RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml.cs diff --git a/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs b/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs new file mode 100644 index 00000000..69aa3986 --- /dev/null +++ b/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs @@ -0,0 +1,26 @@ +namespace RustPlusDesktop.Models +{ + public class CheaterAnalyticsSnapshot + { + public string ServerId { get; set; } + public DateTime Timestamp { get; set; } + public string WipeId { get; set; } + public int ActivePlayerCount { get; set; } + public int ConfirmedCheaterCount { get; set; } + public int SuspectedFlaggedCount { get; set; } + + public double CheaterRatioPercent => + ActivePlayerCount > 0 + ? (ConfirmedCheaterCount + SuspectedFlaggedCount) * 100.0 / ActivePlayerCount + : 0; + + public string RiskBand => + CheaterRatioPercent switch + { + < 5 => "Low", + < 15 => "Moderate", + < 30 => "High", + _ => "Critical" + }; + } +} diff --git a/RustPlusDesktop/Models/CheaterRecord.cs b/RustPlusDesktop/Models/CheaterRecord.cs new file mode 100644 index 00000000..8bd47c62 --- /dev/null +++ b/RustPlusDesktop/Models/CheaterRecord.cs @@ -0,0 +1,23 @@ +namespace RustPlusDesktop.Models +{ + public enum ConfidenceLevel { Low, Medium, High, Confirmed } + public enum FlagSource { AdminManual, PlayerReport, VACBan, GameBan, BattleMetrics } + + public class CheaterRecord + { + public string SteamId { get; set; } + public string DisplayName { get; set; } + public ConfidenceLevel Confidence { get; set; } + public FlagSource Source { get; set; } + public bool IsConfirmedBanned { get; set; } + public int ReportCount { get; set; } + public bool HasVacBan { get; set; } + public bool HasGameBan { get; set; } + public int DaysSinceLastBan { get; set; } + public string EvidenceNotes { get; set; } + public string EvidenceLink { get; set; } + public DateTime FlaggedAt { get; set; } + public DateTime? BanConfirmedAt { get; set; } + public string WipeId { get; set; } + } +} diff --git a/RustPlusDesktop/Services/CheaterAnalyticsService.cs b/RustPlusDesktop/Services/CheaterAnalyticsService.cs new file mode 100644 index 00000000..f4080140 --- /dev/null +++ b/RustPlusDesktop/Services/CheaterAnalyticsService.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using RustPlusDesktop.Models; + +namespace RustPlusDesktop.Services +{ + public class CheaterAnalyticsService + { + private readonly string _dataDir; + + public CheaterAnalyticsService(string appDataDir) + { + _dataDir = appDataDir; + } + + // ── persistence ─────────────────────────────────────────────────────── + + private string RecordPath(string serverId) => + Path.Combine(_dataDir, $"cheater_records_{serverId}.json"); + + public List LoadRecords(string serverId) + { + var path = RecordPath(serverId); + if (!File.Exists(path)) return new(); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json) ?? new(); + } + + public void SaveRecords(string serverId, List records) + { + var json = JsonSerializer.Serialize(records, + new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(RecordPath(serverId), json); + } + + // ── analytics ───────────────────────────────────────────────────────── + + public CheaterAnalyticsSnapshot BuildSnapshot( + string serverId, + int activePlayers, + string wipeId, + List records) + { + var wipeRecords = records.Where(r => r.WipeId == wipeId).ToList(); + return new CheaterAnalyticsSnapshot + { + ServerId = serverId, + Timestamp = DateTime.UtcNow, + WipeId = wipeId, + ActivePlayerCount = activePlayers, + ConfirmedCheaterCount = wipeRecords.Count(r => r.IsConfirmedBanned || + r.Confidence == ConfidenceLevel.Confirmed), + SuspectedFlaggedCount = wipeRecords.Count(r => !r.IsConfirmedBanned && + r.Confidence != ConfidenceLevel.Confirmed) + }; + } + + public List LoadSnapshotHistory(string serverId) + { + var histPath = Path.Combine(_dataDir, $"cheater_snapshots_{serverId}.json"); + if (!File.Exists(histPath)) return new(); + var json = File.ReadAllText(histPath); + return JsonSerializer.Deserialize>(json) ?? new(); + } + + public void AppendSnapshot(string serverId, CheaterAnalyticsSnapshot snap) + { + var history = LoadSnapshotHistory(serverId); + history.Add(snap); + var histPath = Path.Combine(_dataDir, $"cheater_snapshots_{serverId}.json"); + File.WriteAllText(histPath, + JsonSerializer.Serialize(history, + new JsonSerializerOptions { WriteIndented = true })); + } + } +} diff --git a/RustPlusDesktop/Services/SteamBanLookupService.cs b/RustPlusDesktop/Services/SteamBanLookupService.cs new file mode 100644 index 00000000..e8a6b12f --- /dev/null +++ b/RustPlusDesktop/Services/SteamBanLookupService.cs @@ -0,0 +1,38 @@ +using System.Net.Http; +using System.Text.Json; + +namespace RustPlusDesktop.Services +{ + /// + /// Calls the public Steam Web API to retrieve VAC/game-ban status. + /// Only uses the ISteamUser/GetPlayerBans endpoint — no private data. + /// Requires a Steam Web API key stored in app settings (user-provided). + /// + public class SteamBanLookupService + { + private readonly HttpClient _http; + private readonly string _apiKey; + + public SteamBanLookupService(string steamApiKey) + { + _apiKey = steamApiKey; + _http = new HttpClient(); + } + + public async Task<(bool VacBanned, bool GameBanned, int DaysSinceLastBan)> + GetBanStatusAsync(string steamId) + { + var url = $"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/" + + $"?key={_apiKey}&steamids={steamId}"; + var response = await _http.GetStringAsync(url); + using var doc = JsonDocument.Parse(response); + var player = doc.RootElement + .GetProperty("players")[0]; + return ( + player.GetProperty("VACBanned").GetBoolean(), + player.GetProperty("NumberOfGameBans").GetInt32() > 0, + player.GetProperty("DaysSinceLastBan").GetInt32() + ); + } + } +} diff --git a/RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs b/RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs new file mode 100644 index 00000000..cb0fad9f --- /dev/null +++ b/RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs @@ -0,0 +1,183 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using RustPlusDesktop.Models; +using RustPlusDesktop.Services; + +namespace RustPlusDesktop.ViewModels +{ + public class CheaterAnalyticsViewModel : INotifyPropertyChanged + { + private readonly CheaterAnalyticsService _svc; + private readonly SteamBanLookupService _steamSvc; + private readonly string _serverId; + private string _wipeId; + + public ObservableCollection Records { get; } = new(); + public ObservableCollection History { get; } = new(); + + public IEnumerable ConfidenceLevels => + Enum.GetValues(); + public IEnumerable FlagSources => + Enum.GetValues(); + + // ── bound properties ────────────────────────────────────────────────── + + private int _activePlayers; + public int ActivePlayers + { + get => _activePlayers; + set { _activePlayers = value; OnPropertyChanged(); RefreshMetrics(); } + } + + private CheaterAnalyticsSnapshot _current = new(); + public CheaterAnalyticsSnapshot Current + { + get => _current; + private set { _current = value; OnPropertyChanged(); } + } + + // ── new record form fields ───────────────────────────────────────────── + + public string NewSteamId { get; set; } + public string NewName { get; set; } + public ConfidenceLevel NewConfidence { get; set; } = ConfidenceLevel.Low; + public FlagSource NewSource { get; set; } = FlagSource.AdminManual; + public string NewNotes { get; set; } + public string NewEvidenceLink { get; set; } + + // ── commands ────────────────────────────────────────────────────────── + + public ICommand AddRecordCommand { get; } + public ICommand ConfirmBanCommand { get; } + public ICommand DeleteRecordCommand { get; } + + public CheaterAnalyticsViewModel( + CheaterAnalyticsService svc, + SteamBanLookupService steamSvc, + string serverId, + string wipeId) + { + _svc = svc; + _steamSvc = steamSvc; + _serverId = serverId; + _wipeId = wipeId; + + AddRecordCommand = new AsyncRelayCommand(AddRecordWithSteamLookupAsync); + ConfirmBanCommand = new RelayCommand(ConfirmBan); + DeleteRecordCommand = new RelayCommand(DeleteRecord); + + Reload(); + } + + public void Reload() + { + Records.Clear(); + foreach (var r in _svc.LoadRecords(_serverId)) + Records.Add(r); + + History.Clear(); + foreach (var s in _svc.LoadSnapshotHistory(_serverId)) + History.Add(s); + + RefreshMetrics(); + } + + private void RefreshMetrics() + { + Current = _svc.BuildSnapshot(_serverId, ActivePlayers, + _wipeId, Records.ToList()); + } + + public async Task AddRecordWithSteamLookupAsync() + { + if (string.IsNullOrWhiteSpace(NewSteamId)) return; + + var record = new CheaterRecord + { + SteamId = NewSteamId.Trim(), + DisplayName = NewName?.Trim() ?? "Unknown", + Confidence = NewConfidence, + Source = NewSource, + EvidenceNotes = NewNotes, + EvidenceLink = NewEvidenceLink, + FlaggedAt = DateTime.UtcNow, + WipeId = _wipeId + }; + + try + { + var (vac, game, days) = + await _steamSvc.GetBanStatusAsync(NewSteamId); + record.HasVacBan = vac; + record.HasGameBan = game; + record.DaysSinceLastBan = days; + if (vac || game) + record.Confidence = ConfidenceLevel.Confirmed; + } + catch { /* Steam API unavailable — proceed without ban data */ } + + Records.Add(record); + _svc.SaveRecords(_serverId, Records.ToList()); + RefreshMetrics(); + _svc.AppendSnapshot(_serverId, Current); + } + + public void ConfirmBan(CheaterRecord record) + { + record.IsConfirmedBanned = true; + record.Confidence = ConfidenceLevel.Confirmed; + record.BanConfirmedAt = DateTime.UtcNow; + _svc.SaveRecords(_serverId, Records.ToList()); + RefreshMetrics(); + } + + public void DeleteRecord(CheaterRecord record) + { + Records.Remove(record); + _svc.SaveRecords(_serverId, Records.ToList()); + RefreshMetrics(); + } + + // INotifyPropertyChanged + public event PropertyChangedEventHandler PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + // Lightweight command helpers to avoid a full framework dependency + internal class AsyncRelayCommand : ICommand + { + private readonly Func _execute; + private bool _running; + + public AsyncRelayCommand(Func execute) => _execute = execute; + + public bool CanExecute(object _) => !_running; + public event EventHandler CanExecuteChanged; + + public async void Execute(object _) + { + _running = true; + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + try { await _execute(); } + finally + { + _running = false; + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + internal class RelayCommand : ICommand + { + private readonly Action _execute; + + public RelayCommand(Action execute) => _execute = execute; + + public bool CanExecute(object _) => true; + public event EventHandler CanExecuteChanged { add { } remove { } } + public void Execute(object parameter) => _execute((T)parameter); + } +} diff --git a/RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml b/RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml new file mode 100644 index 00000000..ca0cf4f0 --- /dev/null +++ b/RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +