# Game Statistics API Export - Technical Design Document ## Town of Us: Mira Edition **Document Version:** 1.0 **Date:** 2025-10-07 **Related:** GameStats_API_Implementation_Plan.md --- ## Table of Contents 1. [Class Specifications](#class-specifications) 2. [Data Flow Diagrams](#data-flow-diagrams) 3. [Error Handling Strategy](#error-handling-strategy) 4. [Performance Optimization](#performance-optimization) 5. [Code Examples](#code-examples) --- ## Class Specifications ### 1. GameStatsModels.cs **Namespace:** `TownOfUs.Modules.Stats` #### GameStatsData ```csharp /// /// Root data model for game statistics export. /// public class GameStatsData { [JsonPropertyName("token")] public string Token { get; set; } [JsonPropertyName("secret")] public string? Secret { get; set; } [JsonPropertyName("gameInfo")] public GameInfoData GameInfo { get; set; } [JsonPropertyName("players")] public List Players { get; set; } = new(); [JsonPropertyName("gameResult")] public GameResultData GameResult { get; set; } } ``` #### GameInfoData ```csharp /// /// Metadata about the completed game session. /// public class GameInfoData { [JsonPropertyName("gameId")] public string GameId { get; set; } [JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; } [JsonPropertyName("lobbyCode")] public string LobbyCode { get; set; } [JsonPropertyName("gameMode")] public string GameMode { get; set; } [JsonPropertyName("duration")] public float Duration { get; set; } [JsonPropertyName("map")] public string Map { get; set; } } ``` #### PlayerStatsData ```csharp /// /// Complete statistics for a single player. /// public class PlayerStatsData { [JsonPropertyName("playerId")] public int PlayerId { get; set; } [JsonPropertyName("playerName")] public string PlayerName { get; set; } [JsonPropertyName("playerTag")] public string? PlayerTag { get; set; } [JsonPropertyName("platform")] public string Platform { get; set; } [JsonPropertyName("role")] public string Role { get; set; } [JsonPropertyName("roles")] public List Roles { get; set; } = new(); [JsonPropertyName("modifiers")] public List Modifiers { get; set; } = new(); [JsonPropertyName("isWinner")] public bool IsWinner { get; set; } [JsonPropertyName("stats")] public PlayerStatsNumbers Stats { get; set; } } ``` #### PlayerStatsNumbers ```csharp /// /// Numerical statistics for a player. /// public class PlayerStatsNumbers { [JsonPropertyName("totalTasks")] public int TotalTasks { get; set; } [JsonPropertyName("tasksCompleted")] public int TasksCompleted { get; set; } [JsonPropertyName("kills")] public int Kills { get; set; } [JsonPropertyName("correctKills")] public int CorrectKills { get; set; } [JsonPropertyName("incorrectKills")] public int IncorrectKills { get; set; } [JsonPropertyName("correctAssassinKills")] public int CorrectAssassinKills { get; set; } [JsonPropertyName("incorrectAssassinKills")] public int IncorrectAssassinKills { get; set; } } ``` #### GameResultData ```csharp /// /// Information about the game outcome. /// public class GameResultData { [JsonPropertyName("winningTeam")] public string WinningTeam { get; set; } } ``` --- ### 2. ApiConfigManager.cs **Namespace:** `TownOfUs.Modules.Stats` #### ApiConfig ```csharp /// /// Configuration for API export functionality. /// public class ApiConfig { public bool EnableApiExport { get; set; } = false; public string? ApiToken { get; set; } = null; public string? ApiEndpoint { get; set; } = null; public bool SaveLocalBackup { get; set; } = false; public string? Secret { get; set; } = null; /// /// Validates that all required fields are present for API export. /// public bool IsValid() { return EnableApiExport && !string.IsNullOrWhiteSpace(ApiToken) && !string.IsNullOrWhiteSpace(ApiEndpoint); } } ``` #### ApiConfigManager (Static) ```csharp /// /// Manages reading and writing API configuration from INI files. /// public static class ApiConfigManager { private const string ConfigFileName = "ApiSet.ini"; private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; /// /// Reads configuration from INI file in priority order: /// 1. Game directory /// 2. Documents/TownOfUs directory /// public static async Task ReadConfigAsync(); /// /// Creates a default configuration file template. /// private static async Task CreateDefaultConfigAsync(string configPath); /// /// Gets the search paths for configuration files. /// private static IEnumerable GetConfigSearchPaths(); /// /// Parses an INI file into ApiConfig object. /// private static ApiConfig ParseIniFile(string[] lines); /// /// Gets the shared HTTP client instance. /// public static HttpClient GetHttpClient() => HttpClient; } ``` **Implementation Details:** ```csharp public static async Task ReadConfigAsync() { var config = new ApiConfig(); try { foreach (var configPath in GetConfigSearchPaths()) { if (File.Exists(configPath)) { Logger.Info($"Reading config from: {configPath}"); var lines = await File.ReadAllLinesAsync(configPath); config = ParseIniFile(lines); Logger.Info($"Config loaded: EnableExport={config.EnableApiExport}"); return config; } } // No config found - create default var defaultPath = GetConfigSearchPaths().Last(); await CreateDefaultConfigAsync(defaultPath); Logger.Warning($"Config file created at: {defaultPath}"); } catch (Exception ex) { Logger.Error($"Error reading config: {ex.Message}"); } return config; } private static IEnumerable GetConfigSearchPaths() { // 1. Game directory var gameDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); yield return Path.Combine(gameDirectory!, ConfigFileName); // 2. Documents/TownOfUs var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); var touFolder = Path.Combine(documentsPath, "TownOfUs"); Directory.CreateDirectory(touFolder); yield return Path.Combine(touFolder, ConfigFileName); } private static ApiConfig ParseIniFile(string[] lines) { var config = new ApiConfig(); foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line) || line.Trim().StartsWith("#") || line.Trim().StartsWith(";")) continue; var parts = line.Split('=', 2); if (parts.Length != 2) continue; var key = parts[0].Trim(); var value = parts[1].Trim(); switch (key.ToLowerInvariant()) { case "enableapiexport": if (bool.TryParse(value, out bool enableExport)) config.EnableApiExport = enableExport; break; case "apitoken": if (!string.IsNullOrWhiteSpace(value) && value != "null") config.ApiToken = value; break; case "apiendpoint": if (!string.IsNullOrWhiteSpace(value) && value != "null") config.ApiEndpoint = value; break; case "savelocalbackup": if (bool.TryParse(value, out bool saveBackup)) config.SaveLocalBackup = saveBackup; break; case "secret": if (!string.IsNullOrWhiteSpace(value) && value != "null") config.Secret = value; break; } } return config; } ``` --- ### 3. GameDataBuilder.cs **Namespace:** `TownOfUs.Modules.Stats` ```csharp /// /// Transforms EndGameData and GameHistory into exportable format. /// public static class GameDataBuilder { private static readonly Dictionary MapNames = new() { { 0, "The Skeld" }, { 1, "MIRA HQ" }, { 2, "Polus" }, { 3, "Airship" }, { 4, "The Fungle" }, { 5, "Submerged" } }; /// /// Builds complete game statistics data for export. /// public static GameStatsData BuildExportData(ApiConfig config); /// /// Creates GameInfo from current game state. /// private static GameInfoData BuildGameInfo(); /// /// Transforms a player record into exportable format. /// private static PlayerStatsData BuildPlayerData(EndGameData.PlayerRecord record); /// /// Extracts clean role name from RoleBehaviour. /// private static string ExtractRoleName(RoleBehaviour role); /// /// Builds role history array for a player. /// private static List ExtractRoleHistory(byte playerId); /// /// Extracts modifier names from player. /// private static List ExtractModifiers(byte playerId); /// /// Gets player statistics from GameHistory. /// private static PlayerStatsNumbers GetPlayerStats(byte playerId); /// /// Determines the winning team from game state. /// private static string DetermineWinningTeam(); /// /// Gets map name from map ID. /// private static string GetMapName(byte mapId); /// /// Strips color tags from text. /// private static string StripColorTags(string text); /// /// Gets player platform information. /// private static string GetPlayerPlatform(PlayerControl player); /// /// Checks if player is a winner. /// private static bool IsPlayerWinner(EndGameData.PlayerRecord record); } ``` **Key Implementation:** ```csharp public static GameStatsData BuildExportData(ApiConfig config) { Logger.Info("Building game statistics data..."); var gameData = new GameStatsData { Token = config.ApiToken!, Secret = config.Secret, GameInfo = BuildGameInfo(), Players = new List(), GameResult = new GameResultData { WinningTeam = DetermineWinningTeam() } }; // Build player data foreach (var record in EndGamePatches.EndGameData.PlayerRecords) { try { var playerData = BuildPlayerData(record); gameData.Players.Add(playerData); } catch (Exception ex) { Logger.Error($"Error building data for player {record.PlayerName}: {ex.Message}"); } } Logger.Info($"Built data for {gameData.Players.Count} players"); return gameData; } private static GameInfoData BuildGameInfo() { return new GameInfoData { GameId = Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, LobbyCode = InnerNet.GameCode.IntToGameName(AmongUsClient.Instance.GameId), GameMode = GameOptionsManager.Instance?.CurrentGameOptions?.GameMode.ToString() ?? "Unknown", Duration = Time.time, Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0)) }; } private static PlayerStatsData BuildPlayerData(EndGameData.PlayerRecord record) { var player = PlayerControl.AllPlayerControls.FirstOrDefault(x => x.PlayerId == record.PlayerId); var playerData = new PlayerStatsData { PlayerId = record.PlayerId, PlayerName = StripColorTags(record.PlayerName ?? "Unknown"), PlayerTag = player != null ? GetPlayerTag(player) : null, Platform = player != null ? GetPlayerPlatform(player) : "Unknown", IsWinner = record.Winner, Stats = GetPlayerStats(record.PlayerId) }; // Extract roles var roleHistory = ExtractRoleHistory(record.PlayerId); playerData.Roles = roleHistory; playerData.Role = roleHistory.LastOrDefault() ?? "Unknown"; // Extract modifiers playerData.Modifiers = ExtractModifiers(record.PlayerId); return playerData; } private static List ExtractRoleHistory(byte playerId) { var roles = new List(); foreach (var roleEntry in GameHistory.RoleHistory.Where(x => x.Key == playerId)) { var role = roleEntry.Value; // Skip ghost roles if (role.Role is RoleTypes.CrewmateGhost or RoleTypes.ImpostorGhost || role.Role == (RoleTypes)RoleId.Get()) { continue; } var roleName = ExtractRoleName(role); roles.Add(roleName); } return roles; } private static List ExtractModifiers(byte playerId) { var modifiers = new List(); var player = PlayerControl.AllPlayerControls.FirstOrDefault(x => x.PlayerId == playerId); if (player == null) return modifiers; var playerModifiers = player.GetModifiers() .Where(x => x is TouGameModifier || x is UniversalGameModifier); foreach (var modifier in playerModifiers) { modifiers.Add(modifier.ModifierName); } return modifiers; } private static PlayerStatsNumbers GetPlayerStats(byte playerId) { var stats = new PlayerStatsNumbers(); if (GameHistory.PlayerStats.TryGetValue(playerId, out var playerStats)) { stats.CorrectKills = playerStats.CorrectKills; stats.IncorrectKills = playerStats.IncorrectKills; stats.CorrectAssassinKills = playerStats.CorrectAssassinKills; stats.IncorrectAssassinKills = playerStats.IncorrectAssassinKills; } // Get kill count from KilledPlayers stats.Kills = GameHistory.KilledPlayers.Count(x => x.KillerId == playerId && x.VictimId != playerId); // Get task info from player var player = PlayerControl.AllPlayerControls.FirstOrDefault(x => x.PlayerId == playerId); if (player != null) { stats.TotalTasks = player.Data.Tasks.Count; stats.TasksCompleted = player.Data.Tasks.Count(x => x.Complete); } return stats; } private static string DetermineWinningTeam() { // Use GameHistory.WinningFaction if available if (!string.IsNullOrEmpty(GameHistory.WinningFaction)) { return GameHistory.WinningFaction; } // Check winner records var winners = EndGamePatches.EndGameData.PlayerRecords.Where(x => x.Winner).ToList(); if (!winners.Any()) return "Unknown"; var firstWinner = winners.First(); return firstWinner.Team switch { ModdedRoleTeams.Crewmate => "Crewmates", ModdedRoleTeams.Impostor => "Impostors", ModdedRoleTeams.Neutral => "Neutrals", ModdedRoleTeams.Custom => "Custom", _ => "Unknown" }; } private static string ExtractRoleName(RoleBehaviour role) { if (role == null) return "Unknown"; var name = role.GetRoleName(); return StripColorTags(name); } private static string StripColorTags(string text) { if (string.IsNullOrEmpty(text)) return text; // Remove color tags text = Regex.Replace(text, @"", ""); text = text.Replace("", ""); return text.Trim(); } private static string GetPlayerPlatform(PlayerControl player) { if (player?.Data?.Platform != null) { return player.Data.Platform.ToString(); } return "Unknown"; } private static string? GetPlayerTag(PlayerControl player) { if (!string.IsNullOrEmpty(player?.Data?.FriendCode)) { return player.Data.FriendCode; } return null; } private static string GetMapName(byte mapId) { return MapNames.TryGetValue(mapId, out string? mapName) ? mapName : $"Unknown Map ({mapId})"; } ``` --- ### 4. GameStatsExporter.cs **Namespace:** `TownOfUs.Modules.Stats` ```csharp /// /// Orchestrates the export of game statistics to API and/or local storage. /// public static class GameStatsExporter { /// /// Exports game data in the background (fire-and-forget). /// public static Task ExportGameDataBackground(); /// /// Main export method - reads config, builds data, sends to API. /// public static async Task ExportGameDataAsync(); /// /// Sends game data to configured API endpoint. /// private static async Task SendToApiAsync(GameStatsData data, string endpoint); /// /// Saves game data to local JSON file. /// private static async Task SaveLocalBackupAsync(GameStatsData data); /// /// Validates that required data exists for export. /// private static bool ValidateExportData(); } ``` **Full Implementation:** ```csharp public static Task ExportGameDataBackground() { // Fire-and-forget - don't await _ = Task.Run(async () => { try { await ExportGameDataAsync(); } catch (Exception ex) { Logger.Error($"Unhandled error in game stats export: {ex}"); } }); return Task.CompletedTask; } public static async Task ExportGameDataAsync() { try { Logger.Info("=== Game Stats Export Started ==="); // Validate we have data if (!ValidateExportData()) { Logger.Warning("No player data available for export"); return; } // Read configuration var config = await ApiConfigManager.ReadConfigAsync(); if (!config.EnableApiExport) { Logger.Info("API export is disabled - skipping"); return; } if (!config.IsValid()) { Logger.Warning("API configuration is incomplete - skipping export"); return; } // Build export data var gameData = GameDataBuilder.BuildExportData(config); // Save local backup if enabled if (config.SaveLocalBackup) { await SaveLocalBackupAsync(gameData); } // Send to API await SendToApiAsync(gameData, config.ApiEndpoint!); Logger.Info("=== Game Stats Export Completed Successfully ==="); } catch (Exception ex) { Logger.Error($"Error during game stats export: {ex}"); } } private static async Task SendToApiAsync(GameStatsData data, string endpoint) { try { Logger.Info($"Sending data to API: {endpoint}"); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; var jsonData = JsonSerializer.Serialize(data, jsonOptions); var content = new StringContent(jsonData, Encoding.UTF8, "application/json"); var httpClient = ApiConfigManager.GetHttpClient(); httpClient.DefaultRequestHeaders.Clear(); httpClient.DefaultRequestHeaders.Add("User-Agent", $"TownOfUs-Mira-DataExporter/{TownOfUsPlugin.Version}"); var response = await httpClient.PostAsync(endpoint, content); if (response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); Logger.Info($"API response: {responseContent}"); } else { var errorContent = await response.Content.ReadAsStringAsync(); Logger.Error($"API returned error: {response.StatusCode} - {errorContent}"); } } catch (HttpRequestException httpEx) { Logger.Error($"HTTP error sending to API: {httpEx.Message}"); } catch (TaskCanceledException) { Logger.Error("API request timeout (30 seconds exceeded)"); } catch (Exception ex) { Logger.Error($"Unexpected error sending to API: {ex.Message}"); } } private static async Task SaveLocalBackupAsync(GameStatsData data) { try { var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); var logFolder = Path.Combine(documentsPath, "TownOfUs", "GameLogs"); Directory.CreateDirectory(logFolder); var gameIdShort = data.GameInfo.GameId.Substring(0, 8); var fileName = $"Game_{DateTime.Now:yyyyMMdd_HHmmss}_{gameIdShort}.json"; var filePath = Path.Combine(logFolder, fileName); var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; var jsonData = JsonSerializer.Serialize(data, jsonOptions); await File.WriteAllTextAsync(filePath, jsonData); Logger.Info($"Local backup saved: {filePath}"); } catch (Exception ex) { Logger.Error($"Failed to save local backup: {ex.Message}"); } } private static bool ValidateExportData() { if (EndGamePatches.EndGameData.PlayerRecords == null || EndGamePatches.EndGameData.PlayerRecords.Count == 0) { return false; } return true; } ``` --- ## Data Flow Diagrams ### High-Level Export Flow ``` ┌────────────────────────────────────────────────────────────────┐ │ Game Ends │ └────────────────┬───────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ AmongUsClient.OnGameEnd [Harmony Patch] │ │ - BuildEndGameData() │ │ - Populate EndGameData.PlayerRecords │ └────────────────┬───────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────┐ │ EndGameManager.Start [Harmony Patch] │ │ - BuildEndGameSummary() (UI display) │ │ - GameStatsExporter.ExportGameDataBackground() │ └────────────────┬───────────────────────────────────────────────┘ │ ▼ (async Task.Run) ┌────────────────────────────────────────────────────────────────┐ │ GameStatsExporter.ExportGameDataAsync() │ └────────────────┬───────────────────────────────────────────────┘ │ ├──────────────────┬────────────────┐ ▼ ▼ ▼ ┌──────────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ ApiConfigManager │ │ GameDataBuilder │ │ Validate │ │ .ReadConfigAsync() │ │ .BuildExportData()│ │ Data Exists │ └──────────────────────┘ └──────────────────┘ └──────────────┘ │ ├────────────────┬──────────────────┐ ▼ ▼ ▼ ┌──────────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ Check Config Valid │ │ SaveLocalBackup │ │ SendToApi │ │ EnableApiExport=true │ │ Async() │ │ Async() │ └──────────────────────┘ └──────────────────┘ └──────────────┘ ``` ### Data Transformation Flow ``` ┌─────────────────────────────────────────────────────────────┐ │ EndGameData.PlayerRecords │ │ - PlayerName: "Syzyf" │ │ - RoleString: "Medic > ..." │ │ - Winner: true │ │ - LastRole: RoleTypes │ │ - Team: ModdedRoleTeams.Crewmate │ │ - PlayerId: 0 │ └─────────────────┬───────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ GameDataBuilder.BuildPlayerData() │ │ │ │ 1. StripColorTags(PlayerName) → "Syzyf" │ │ 2. ExtractRoleHistory(PlayerId) │ │ - Read GameHistory.RoleHistory │ │ - Filter out ghost roles │ │ - Extract clean role names │ │ → ["Medic", "Crewmate", "Haunter"] │ │ 3. ExtractModifiers(PlayerId) │ │ - Get player.GetModifiers() │ │ - Extract modifier names │ │ → ["Button Barry", "Frosty"] │ │ 4. GetPlayerStats(PlayerId) │ │ - Read GameHistory.PlayerStats[playerId] │ │ - Read player.Data.Tasks │ │ - Count kills from GameHistory.KilledPlayers │ │ 5. GetPlayerPlatform(player) │ │ 6. GetPlayerTag(player) │ └─────────────────┬───────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ PlayerStatsData (Output) │ │ { │ │ "playerId": 0, │ │ "playerName": "Syzyf", │ │ "playerTag": null, │ │ "platform": "Steam", │ │ "role": "Haunter", │ │ "roles": ["Medic", "Crewmate", "Haunter"], │ │ "modifiers": ["Button Barry", "Frosty"], │ │ "isWinner": true, │ │ "stats": { ... } │ │ } │ └─────────────────────────────────────────────────────────────┘ ``` --- ## Error Handling Strategy ### Exception Hierarchy ``` Try/Catch Layers: 1. Top Level (ExportGameDataBackground) └─ Catches: ALL exceptions └─ Action: Log critical error, prevent crash 2. Export Level (ExportGameDataAsync) └─ Catches: ALL exceptions └─ Action: Log error, cleanup, return gracefully 3. Component Level (SendToApi, SaveLocalBackup, BuildData) └─ Catches: Specific exceptions (HttpRequestException, IOException, etc.) └─ Action: Log specific error, continue with other operations 4. Data Level (BuildPlayerData, ExtractRoleHistory) └─ Catches: Specific player data errors └─ Action: Skip problematic player, continue with others ``` ### Error Scenarios and Responses | Scenario | Detection | Response | User Impact | |----------|-----------|----------|-------------| | No config file | File.Exists() returns false | Create default template, log warning | None - defaults to disabled | | Invalid config values | Parsing fails | Use default values, log warning | Export disabled safely | | No network connection | HttpRequestException | Log error, skip API send | Local backup still works | | API timeout | TaskCanceledException | Log timeout, continue | No retry (fire-and-forget) | | API returns 4xx/5xx | response.IsSuccessStatusCode == false | Log error with response body | Data logged for debugging | | JSON serialization fails | JsonException | Log error with data, skip export | Game continues normally | | Local file write fails | IOException | Log error, continue with API send | API export still works | | No player data | PlayerRecords.Count == 0 | Skip export entirely | No unnecessary API calls | | Player data incomplete | Null reference | Skip problematic player, continue | Partial data exported | ### Logging Examples ```csharp // Success Logger.Info("=== Game Stats Export Completed Successfully ==="); Logger.Info($"Built data for {gameData.Players.Count} players"); Logger.Info($"Local backup saved: {filePath}"); // Warnings Logger.Warning("API export is disabled - skipping"); Logger.Warning("No player data available for export"); Logger.Warning($"Config file created at: {configPath}"); // Errors Logger.Error($"Error reading config: {ex.Message}"); Logger.Error($"API returned error: {response.StatusCode} - {errorContent}"); Logger.Error($"HTTP error sending to API: {httpEx.Message}"); Logger.Error("API request timeout (30 seconds exceeded)"); Logger.Error($"Failed to save local backup: {ex.Message}"); Logger.Error($"Error building data for player {record.PlayerName}: {ex.Message}"); ``` --- ## Performance Optimization ### Async Execution Strategy ```csharp // CORRECT: Non-blocking UI [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))] public static void EndGameManagerStart(EndGameManager __instance) { BuildEndGameSummary(__instance); // Synchronous UI setup // Fire-and-forget async export (doesn't block) _ = GameStatsExporter.ExportGameDataBackground(); } // INCORRECT: Would block UI // await GameStatsExporter.ExportGameDataAsync(); // DON'T DO THIS ``` ### Data Copy Strategy **Problem:** `EndGameData.PlayerRecords` could be cleared while export is running **Solution:** MiraAPI's EndGameData is already persistent, but we validate before use ```csharp private static bool ValidateExportData() { // Check data still exists before processing if (EndGamePatches.EndGameData.PlayerRecords == null || EndGamePatches.EndGameData.PlayerRecords.Count == 0) { return false; } return true; } ``` ### HTTP Client Reuse **Problem:** Creating HttpClient instances is expensive **Solution:** Static singleton with proper configuration ```csharp // In ApiConfigManager private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; public static HttpClient GetHttpClient() => HttpClient; ``` ### JSON Serialization Options ```csharp // For API (compact) var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, // Compact DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; // For local backup (readable) var jsonOptions = new JsonSerializerOptions { WriteIndented = true, // Pretty-printed PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; ``` ### Performance Benchmarks | Operation | Expected Duration | Max Acceptable | |-----------|------------------|----------------| | Read config file | < 10ms | 50ms | | Build export data | < 100ms | 500ms | | JSON serialization | < 50ms | 200ms | | HTTP POST | < 1000ms | 30000ms (timeout) | | Save local backup | < 100ms | 500ms | | **Total Export** | **< 1500ms** | **30000ms** | **Note:** All export operations run async - **0ms UI blocking time** --- ## Code Examples ### Example 1: Adding Export to EndGamePatches.cs ```csharp // In TownOfUs/Patches/EndGamePatches.cs [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))] [HarmonyPostfix] public static void EndGameManagerStart(EndGameManager __instance) { // Existing code BuildEndGameSummary(__instance); // NEW: Export game statistics if (GameOptionsManager.Instance.CurrentGameOptions.GameMode != GameModes.HideNSeek) { _ = GameStatsExporter.ExportGameDataBackground(); } } ``` ### Example 2: Testing Configuration Reading ```csharp // Test program to verify config reading public static async Task TestConfigReading() { var config = await ApiConfigManager.ReadConfigAsync(); Console.WriteLine($"EnableApiExport: {config.EnableApiExport}"); Console.WriteLine($"ApiToken: {(string.IsNullOrEmpty(config.ApiToken) ? "(empty)" : "***SET***")}"); Console.WriteLine($"ApiEndpoint: {config.ApiEndpoint ?? "(empty)"}"); Console.WriteLine($"SaveLocalBackup: {config.SaveLocalBackup}"); Console.WriteLine($"Secret: {(string.IsNullOrEmpty(config.Secret) ? "(empty)" : "***SET***")}"); Console.WriteLine($"IsValid: {config.IsValid()}"); } ``` ### Example 3: Manual Export Trigger (Debug Command) ```csharp // In TownOfUs/Patches/Misc/ChatCommandsPatch.cs // Add a debug command to manually trigger export if (text.ToLower().StartsWith("/exportstats")) { if (!AmongUsClient.Instance.AmHost) { chatBubble.SetText("Only the host can use this command"); return; } // Manually trigger export _ = GameStatsExporter.ExportGameDataBackground(); chatBubble.SetText("Game stats export triggered!"); return; } ``` ### Example 4: Validating JSON Output ```csharp // Utility to validate exported JSON public static bool ValidateExportedJson(string jsonPath) { try { var jsonContent = File.ReadAllText(jsonPath); var data = JsonSerializer.Deserialize(jsonContent); if (data == null) return false; if (data.GameInfo == null) return false; if (data.Players == null || data.Players.Count == 0) return false; if (string.IsNullOrEmpty(data.GameInfo.LobbyCode)) return false; Console.WriteLine($"✓ Valid JSON with {data.Players.Count} players"); Console.WriteLine($" Game: {data.GameInfo.LobbyCode} on {data.GameInfo.Map}"); Console.WriteLine($" Winner: {data.GameResult.WinningTeam}"); return true; } catch (Exception ex) { Console.WriteLine($"✗ Invalid JSON: {ex.Message}"); return false; } } ``` --- **End of Technical Design Document**