diff --git a/.claude/worktrees/agent-a183347c27982ce6d b/.claude/worktrees/agent-a183347c27982ce6d new file mode 160000 index 00000000..5f01a1b7 --- /dev/null +++ b/.claude/worktrees/agent-a183347c27982ce6d @@ -0,0 +1 @@ +Subproject commit 5f01a1b776feb429e7b74df69ec31be0c6f6405c diff --git a/CHEATER_ANALYTICS.md b/CHEATER_ANALYTICS.md new file mode 100644 index 00000000..8f984246 --- /dev/null +++ b/CHEATER_ANALYTICS.md @@ -0,0 +1,120 @@ +# 🛡️ Cheater Analytics — Feature Branch + +> **Branch:** `claude/focused-pasteur-2pCsw` +> **Based on:** Rust+ Desktop v5.4.0 by [Pronwan](https://github.com/Pronwan) +> **Status:** Ready for review / merge to main + +--- + +## What Was Built + +This branch adds a **Cheater-to-Player Ratio Analytics** panel to Rust+ Desktop — a local, privacy-respecting tool that helps server admins track, flag, and report suspected cheaters during a wipe. + +No IP collection. No hardware fingerprinting. No Discord scraping. All data stays local. + +--- + +## Feature Overview + +### Shield Button +A new shield icon button (🛡️) appears in every server card header. Clicking it opens the Cheater Analytics sidebar panel. + +### Current Wipe Snapshot +Live metrics updated as you flag players: + +| Metric | Description | +|---|---| +| **Players** | Active player count (manually entered) | +| **Confirmed** | Players with confirmed bans or marked confirmed | +| **Suspected** | Flagged players not yet confirmed | +| **Risk %** | `(Confirmed + Suspected) / Players × 100` | +| **Risk Level** | Low / Moderate / High / Critical colour-coded band | + +Risk bands: +- 🟢 **Low** — 0–5% +- 🟡 **Moderate** — 5–15% +- 🟠 **High** — 15–30% +- 🔴 **Critical** — >30% + +### Flag a Player +Expand the **+ Flag a Player** section to add a suspect: +- Steam ID (triggers automatic VAC/game ban lookup via Steam public API) +- Display name, confidence level, flag source, notes, evidence link + +### Report & Export +Once players are flagged, one click sends a full report: + +| Button | Action | +|---|---| +| 📋 **Copy F7 List** | Builds a plain-text F7-style report, saves to Desktop, copies to clipboard | +| 💾 **Export CSV** | Saves a CSV of all flagged players to Desktop | +| 📨 **Send Discord** | Posts a formatted embed to your Discord server via webhook | + +The Discord webhook URL is entered once and **persisted automatically** — it pre-fills every time you open the panel. + +### Discord Report Example + +![Discord report embed showing flagged players](docs/discord_report_preview.png) + +The embed includes server name, wipe date, confirmed/suspected counts, and a full list of flagged players with confidence level, source, and evidence notes. + +--- + +## New Files + +| File | Purpose | +|---|---| +| `RustPlusDesktop/Models/CheaterRecord.cs` | Data model for a flagged player | +| `RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs` | Computed wipe snapshot with ratio + risk band | +| `RustPlusDesktop/Services/CheaterAnalyticsService.cs` | Local JSON persistence and snapshot builder | +| `RustPlusDesktop/Services/SteamBanLookupService.cs` | Steam `ISteamUser/GetPlayerBans` public API wrapper | +| `RustPlusDesktop/Services/CheaterReportService.cs` | CSV, F7 text, and Discord webhook report builder | +| `RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs` | MVVM ViewModel with all commands | +| `RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml` | WPF sidebar panel UI | +| `RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml.cs` | Code-behind | + +### Modified Files + +| File | Change | +|---|---| +| `RustPlusDesktop/MainWindow.xaml` | Shield button + panel overlay | +| `RustPlusDesktop/MainWindow.xaml.cs` | Button handler, lazy ViewModel init | + +--- + +## Data & Privacy + +- All flagged player data is stored locally at `%AppData%\RustPlusDesk\cheater_records_{serverId}.json` +- Snapshot history at `%AppData%\RustPlusDesk\cheater_snapshots_{serverId}.json` +- Discord webhook URL cached at `%AppData%\RustPlusDesk\cache\cheater_discord_webhook.json` +- The only outbound network calls are: + - Steam public `ISteamUser/GetPlayerBans` API (optional, requires user-provided API key) + - Discord webhook POST (only when user clicks **Send Discord**) + +--- + +## How to Test Locally + +```powershell +# Clone or pull the branch +git clone https://github.com/Pronwan/rustplus-desktop-qa.git +git checkout claude/focused-pasteur-2pCsw + +cd RustPlusDesktop + +# Create required local secrets file (gitignored) +# Copy ObfuscatedSecrets.cs from your working install or create a stub + +dotnet run +``` + +Then click the 🛡️ shield button on any server card. + +--- + +## Discord Webhook Setup + +1. Open your Discord server → **Server Settings → Integrations → Webhooks** +2. Click **New Webhook**, pick a channel, copy the URL +3. Paste into the webhook box in the Cheater Analytics panel +4. Click **📨 Send Discord** — the URL saves automatically for next time diff --git a/RustPlusDesktop/MainWindow.xaml b/RustPlusDesktop/MainWindow.xaml index fba2c5e2..cc2867c4 100644 --- a/RustPlusDesktop/MainWindow.xaml +++ b/RustPlusDesktop/MainWindow.xaml @@ -1545,6 +1545,16 @@ Icon="{ui:SymbolIcon Symbol=Info20}" ToolTip="{DynamicResource ServerDetails}" /> + + + @@ -3329,6 +3339,11 @@ + + + diff --git a/RustPlusDesktop/MainWindow.xaml.cs b/RustPlusDesktop/MainWindow.xaml.cs index fb12599c..7a8f973c 100644 --- a/RustPlusDesktop/MainWindow.xaml.cs +++ b/RustPlusDesktop/MainWindow.xaml.cs @@ -4673,6 +4673,55 @@ private void BtnLanguageSettings_Click(object sender, RoutedEventArgs e) BtnSettings_Click(sender, e); } + private CheaterAnalyticsViewModel _cheaterVm; + + private void BtnCheaterAnalytics_Click(object sender, RoutedEventArgs e) + { + try + { + if (CheaterAnalyticsPanel.Visibility == Visibility.Visible) + { + CheaterAnalyticsPanel.Visibility = Visibility.Collapsed; + return; + } + + // Collapse other overlays (same pattern as settings/profit panels) + ProfitTradesPanel.Visibility = Visibility.Collapsed; + BuyXForYPanel.Visibility = Visibility.Collapsed; + AppSettingsPanel.Visibility = Visibility.Collapsed; + + // Lazy-init the ViewModel on first open + if (_cheaterVm == null) + { + var dataDir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "RustPlusDesk"); + System.IO.Directory.CreateDirectory(dataDir); + + var serverId = _vm.Selected?.SteamId64 ?? "default"; + var wipeId = _vm.Selected?.RustMapsWipeTime?.ToString("yyyyMMdd") ?? "wipe0"; + var serverName = _vm.Selected?.Name ?? serverId; + + var analyticsSvc = new CheaterAnalyticsService(dataDir); + var steamSvc = new SteamBanLookupService(""); + + _cheaterVm = new CheaterAnalyticsViewModel(analyticsSvc, steamSvc, serverId, wipeId, serverName); + } + else + { + _cheaterVm.Reload(); + } + + CheaterAnalyticsPanel.DataContext = _cheaterVm; + CheaterAnalyticsPanel.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + MessageBox.Show($"Cheater Analytics error:\n\n{ex.Message}\n\n{ex.InnerException?.Message}", + "Analytics Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + public void UpdateLanguageFlag() { if (ImgLanguageFlag == null) return; diff --git a/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs b/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs new file mode 100644 index 00000000..9f1f2d0b --- /dev/null +++ b/RustPlusDesktop/Models/CheaterAnalyticsSnapshot.cs @@ -0,0 +1,28 @@ +using System; + +namespace RustPlusDesk.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..da37498a --- /dev/null +++ b/RustPlusDesktop/Models/CheaterRecord.cs @@ -0,0 +1,25 @@ +using System; + +namespace RustPlusDesk.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/RustPlusDesk.csproj b/RustPlusDesktop/RustPlusDesk.csproj index 6ba605b1..c01e451e 100644 --- a/RustPlusDesktop/RustPlusDesk.csproj +++ b/RustPlusDesktop/RustPlusDesk.csproj @@ -376,7 +376,8 @@ 250 - + diff --git a/RustPlusDesktop/Services/CheaterAnalyticsService.cs b/RustPlusDesktop/Services/CheaterAnalyticsService.cs new file mode 100644 index 00000000..4504ab9d --- /dev/null +++ b/RustPlusDesktop/Services/CheaterAnalyticsService.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using RustPlusDesk.Models; + +namespace RustPlusDesk.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/CheaterReportService.cs b/RustPlusDesktop/Services/CheaterReportService.cs new file mode 100644 index 00000000..90ea2f1b --- /dev/null +++ b/RustPlusDesktop/Services/CheaterReportService.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using RustPlusDesk.Models; + +namespace RustPlusDesk.Services +{ + public class CheaterReportService + { + private readonly HttpClient _http = new(); + + // ── CSV Export ──────────────────────────────────────────────────────── + public string BuildCsv(IEnumerable records) + { + var sb = new StringBuilder(); + sb.AppendLine("SteamID,DisplayName,Confidence,Source,VAC,GameBan,DaysSinceBan,ReportCount,Confirmed,FlaggedAt,EvidenceNotes,EvidenceLink"); + foreach (var r in records) + { + sb.AppendLine(string.Join(",", + CsvCell(r.SteamId), + CsvCell(r.DisplayName), + r.Confidence.ToString(), + r.Source.ToString(), + r.HasVacBan ? "YES" : "NO", + r.HasGameBan ? "YES" : "NO", + r.DaysSinceLastBan.ToString(), + r.ReportCount.ToString(), + r.IsConfirmedBanned ? "YES" : "NO", + r.FlaggedAt.ToString("yyyy-MM-dd HH:mm"), + CsvCell(r.EvidenceNotes ?? ""), + CsvCell(r.EvidenceLink ?? "") + )); + } + return sb.ToString(); + } + + public void SaveCsv(IEnumerable records, string filePath) + { + File.WriteAllText(filePath, BuildCsv(records), Encoding.UTF8); + } + + // ── F7 Report Text ──────────────────────────────────────────────────── + public string BuildF7ReportText(IEnumerable records) + { + var sb = new StringBuilder(); + sb.AppendLine("=== CHEATER REPORT — Copy SteamIDs below into F7 Report ==="); + sb.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); + sb.AppendLine(); + foreach (var r in records) + { + sb.AppendLine($"Player: {r.DisplayName}"); + sb.AppendLine($"SteamID: {r.SteamId}"); + sb.AppendLine($"Confidence: {r.Confidence} | Source: {r.Source}"); + if (r.HasVacBan) sb.AppendLine($"VAC Ban: YES ({r.DaysSinceLastBan} days ago)"); + if (r.HasGameBan) sb.AppendLine($"Game Ban: YES"); + if (!string.IsNullOrWhiteSpace(r.EvidenceNotes)) + sb.AppendLine($"Notes: {r.EvidenceNotes}"); + if (!string.IsNullOrWhiteSpace(r.EvidenceLink)) + sb.AppendLine($"Evidence: {r.EvidenceLink}"); + sb.AppendLine(new string('-', 50)); + } + return sb.ToString(); + } + + // ── Discord Webhook ─────────────────────────────────────────────────── + public async Task SendDiscordReportAsync( + string webhookUrl, + string serverName, + IEnumerable records) + { + var list = records.ToList(); + var confirmed = list.Count(r => r.IsConfirmedBanned || r.Confidence == ConfidenceLevel.Confirmed); + var suspected = list.Count(r => !r.IsConfirmedBanned && r.Confidence != ConfidenceLevel.Confirmed); + + // Build fields — Discord embed field limit is 25 + var fields = new List(); + foreach (var r in list.Take(20)) + { + var badges = new List(); + if (r.HasVacBan) badges.Add("🔴 VAC"); + if (r.HasGameBan) badges.Add("🟠 GameBan"); + if (r.IsConfirmedBanned) badges.Add("✅ Confirmed"); + var badgeStr = badges.Count > 0 ? string.Join(" ", badges) : ""; + + var val = new StringBuilder(); + val.AppendLine($"`{r.SteamId}`"); + val.AppendLine($"Confidence: **{r.Confidence}** | Source: {r.Source}"); + if (!string.IsNullOrWhiteSpace(badgeStr)) val.AppendLine(badgeStr); + if (!string.IsNullOrWhiteSpace(r.EvidenceNotes)) + val.AppendLine($"_{TruncateStr(r.EvidenceNotes, 80)}_"); + + fields.Add(new + { + name = $"⚠️ {r.DisplayName}", + value = val.ToString().Trim(), + inline = false + }); + } + + var embed = new + { + title = $"🛡 Cheater Report — {serverName}", + description = $"**{confirmed}** confirmed | **{suspected}** suspected | **{list.Count}** total flagged", + color = confirmed > 0 ? 15158332 : 15105570, // red or orange + timestamp = DateTime.UtcNow.ToString("o"), + footer = new { text = "Rust+ Desktop — Cheater Analytics" }, + fields + }; + + var payload = JsonSerializer.Serialize(new { embeds = new[] { embed } }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(webhookUrl, content); + response.EnsureSuccessStatusCode(); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + private static string CsvCell(string val) + { + if (val.Contains(',') || val.Contains('"') || val.Contains('\n')) + return $"\"{val.Replace("\"", "\"\"")}\""; + return val; + } + + private static string TruncateStr(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; + } +} diff --git a/RustPlusDesktop/Services/SteamBanLookupService.cs b/RustPlusDesktop/Services/SteamBanLookupService.cs new file mode 100644 index 00000000..4b4fb65a --- /dev/null +++ b/RustPlusDesktop/Services/SteamBanLookupService.cs @@ -0,0 +1,40 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; + +namespace RustPlusDesk.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..6dde5dc7 --- /dev/null +++ b/RustPlusDesktop/ViewModels/CheaterAnalyticsViewModel.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using RustPlusDesk.Models; +using RustPlusDesk.Services; +using RustPlusDesk.Services.Data; + +namespace RustPlusDesk.ViewModels +{ + public class CheaterAnalyticsViewModel : INotifyPropertyChanged + { + private readonly CheaterAnalyticsService _svc; + private readonly SteamBanLookupService _steamSvc; + private readonly CheaterReportService _reportSvc; + private readonly string _serverId; + private readonly string _serverName; + 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; } + + // ── report/export properties ────────────────────────────────────────── + + private const string WebhookCacheKey = "cheater_discord_webhook"; + + private string _discordWebhookUrl; + public string DiscordWebhookUrl + { + get => _discordWebhookUrl; + set + { + _discordWebhookUrl = value; + OnPropertyChanged(); + DataManager.SaveCache(WebhookCacheKey, value); + } + } + + private string _reportStatus; + public string ReportStatus + { + get => _reportStatus; + set { _reportStatus = value; OnPropertyChanged(); } + } + + // ── commands ────────────────────────────────────────────────────────── + + public ICommand AddRecordCommand { get; } + public ICommand ConfirmBanCommand { get; } + public ICommand DeleteRecordCommand { get; } + public ICommand ExportCsvCommand { get; } + public ICommand ExportTxtCommand { get; } + public ICommand SendDiscordCommand { get; } + + public CheaterAnalyticsViewModel( + CheaterAnalyticsService svc, + SteamBanLookupService steamSvc, + string serverId, + string wipeId, + string serverName = null) + { + _svc = svc; + _steamSvc = steamSvc; + _serverId = serverId; + _serverName = serverName ?? serverId; + _wipeId = wipeId; + _reportSvc = new CheaterReportService(); + _discordWebhookUrl = DataManager.LoadCache(WebhookCacheKey) ?? ""; + + AddRecordCommand = new AsyncRelayCommand(AddRecordWithSteamLookupAsync); + ConfirmBanCommand = new RelayCommand(ConfirmBan); + DeleteRecordCommand = new RelayCommand(DeleteRecord); + ExportCsvCommand = new RelayCommand(_ => ExportCsv()); + ExportTxtCommand = new RelayCommand(_ => ExportTxt()); + SendDiscordCommand = new AsyncRelayCommand(SendDiscordAsync); + + Reload(); + } + + private void ExportCsv() + { + try + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + var date = DateTime.UtcNow.ToString("yyyyMMdd"); + var path = Path.Combine(desktop, $"cheater_report_{_serverId}_{date}.csv"); + _reportSvc.SaveCsv(Records, path); + ReportStatus = "✅ CSV saved to Desktop"; + } + catch (Exception ex) + { + ReportStatus = $"❌ CSV error: {ex.Message}"; + } + } + + private void ExportTxt() + { + try + { + var text = _reportSvc.BuildF7ReportText(Records); + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + var date = DateTime.UtcNow.ToString("yyyyMMdd"); + var path = Path.Combine(desktop, $"cheater_report_{_serverId}_{date}.txt"); + File.WriteAllText(path, text, System.Text.Encoding.UTF8); + System.Windows.Clipboard.SetText(text); + ReportStatus = "✅ TXT saved to Desktop"; + } + catch (Exception ex) + { + ReportStatus = $"❌ TXT error: {ex.Message}"; + } + } + + private async Task SendDiscordAsync() + { + if (string.IsNullOrWhiteSpace(DiscordWebhookUrl)) + { + ReportStatus = "⚠️ No webhook URL set"; + return; + } + try + { + await _reportSvc.SendDiscordReportAsync(DiscordWebhookUrl, _serverName, Records); + ReportStatus = "✅ Sent to Discord"; + } + catch (Exception ex) + { + ReportStatus = $"❌ Discord error: {ex.Message}"; + } + } + + 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..820146e8 --- /dev/null +++ b/RustPlusDesktop/Views/CheaterAnalyticsPanel.xaml @@ -0,0 +1,515 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +