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
+
+
+
+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