37 KiB
37 KiB
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
- Class Specifications
- Data Flow Diagrams
- Error Handling Strategy
- Performance Optimization
- Code Examples
Class Specifications
1. GameStatsModels.cs
Namespace: TownOfUs.Modules.Stats
GameStatsData
/// <summary>
/// Root data model for game statistics export.
/// </summary>
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<PlayerStatsData> Players { get; set; } = new();
[JsonPropertyName("gameResult")]
public GameResultData GameResult { get; set; }
}
GameInfoData
/// <summary>
/// Metadata about the completed game session.
/// </summary>
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
/// <summary>
/// Complete statistics for a single player.
/// </summary>
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<string> Roles { get; set; } = new();
[JsonPropertyName("modifiers")]
public List<string> Modifiers { get; set; } = new();
[JsonPropertyName("isWinner")]
public bool IsWinner { get; set; }
[JsonPropertyName("stats")]
public PlayerStatsNumbers Stats { get; set; }
}
PlayerStatsNumbers
/// <summary>
/// Numerical statistics for a player.
/// </summary>
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
/// <summary>
/// Information about the game outcome.
/// </summary>
public class GameResultData
{
[JsonPropertyName("winningTeam")]
public string WinningTeam { get; set; }
}
2. ApiConfigManager.cs
Namespace: TownOfUs.Modules.Stats
ApiConfig
/// <summary>
/// Configuration for API export functionality.
/// </summary>
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;
/// <summary>
/// Validates that all required fields are present for API export.
/// </summary>
public bool IsValid()
{
return EnableApiExport
&& !string.IsNullOrWhiteSpace(ApiToken)
&& !string.IsNullOrWhiteSpace(ApiEndpoint);
}
}
ApiConfigManager (Static)
/// <summary>
/// Manages reading and writing API configuration from INI files.
/// </summary>
public static class ApiConfigManager
{
private const string ConfigFileName = "ApiSet.ini";
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
/// <summary>
/// Reads configuration from INI file in priority order:
/// 1. Game directory
/// 2. Documents/TownOfUs directory
/// </summary>
public static async Task<ApiConfig> ReadConfigAsync();
/// <summary>
/// Creates a default configuration file template.
/// </summary>
private static async Task CreateDefaultConfigAsync(string configPath);
/// <summary>
/// Gets the search paths for configuration files.
/// </summary>
private static IEnumerable<string> GetConfigSearchPaths();
/// <summary>
/// Parses an INI file into ApiConfig object.
/// </summary>
private static ApiConfig ParseIniFile(string[] lines);
/// <summary>
/// Gets the shared HTTP client instance.
/// </summary>
public static HttpClient GetHttpClient() => HttpClient;
}
Implementation Details:
public static async Task<ApiConfig> ReadConfigAsync()
{
var config = new ApiConfig();
try
{
foreach (var configPath in GetConfigSearchPaths())
{
if (File.Exists(configPath))
{
Logger<TownOfUsPlugin>.Info($"Reading config from: {configPath}");
var lines = await File.ReadAllLinesAsync(configPath);
config = ParseIniFile(lines);
Logger<TownOfUsPlugin>.Info($"Config loaded: EnableExport={config.EnableApiExport}");
return config;
}
}
// No config found - create default
var defaultPath = GetConfigSearchPaths().Last();
await CreateDefaultConfigAsync(defaultPath);
Logger<TownOfUsPlugin>.Warning($"Config file created at: {defaultPath}");
}
catch (Exception ex)
{
Logger<TownOfUsPlugin>.Error($"Error reading config: {ex.Message}");
}
return config;
}
private static IEnumerable<string> 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
/// <summary>
/// Transforms EndGameData and GameHistory into exportable format.
/// </summary>
public static class GameDataBuilder
{
private static readonly Dictionary<byte, string> MapNames = new()
{
{ 0, "The Skeld" },
{ 1, "MIRA HQ" },
{ 2, "Polus" },
{ 3, "Airship" },
{ 4, "The Fungle" },
{ 5, "Submerged" }
};
/// <summary>
/// Builds complete game statistics data for export.
/// </summary>
public static GameStatsData BuildExportData(ApiConfig config);
/// <summary>
/// Creates GameInfo from current game state.
/// </summary>
private static GameInfoData BuildGameInfo();
/// <summary>
/// Transforms a player record into exportable format.
/// </summary>
private static PlayerStatsData BuildPlayerData(EndGameData.PlayerRecord record);
/// <summary>
/// Extracts clean role name from RoleBehaviour.
/// </summary>
private static string ExtractRoleName(RoleBehaviour role);
/// <summary>
/// Builds role history array for a player.
/// </summary>
private static List<string> ExtractRoleHistory(byte playerId);
/// <summary>
/// Extracts modifier names from player.
/// </summary>
private static List<string> ExtractModifiers(byte playerId);
/// <summary>
/// Gets player statistics from GameHistory.
/// </summary>
private static PlayerStatsNumbers GetPlayerStats(byte playerId);
/// <summary>
/// Determines the winning team from game state.
/// </summary>
private static string DetermineWinningTeam();
/// <summary>
/// Gets map name from map ID.
/// </summary>
private static string GetMapName(byte mapId);
/// <summary>
/// Strips color tags from text.
/// </summary>
private static string StripColorTags(string text);
/// <summary>
/// Gets player platform information.
/// </summary>
private static string GetPlayerPlatform(PlayerControl player);
/// <summary>
/// Checks if player is a winner.
/// </summary>
private static bool IsPlayerWinner(EndGameData.PlayerRecord record);
}
Key Implementation:
public static GameStatsData BuildExportData(ApiConfig config)
{
Logger<TownOfUsPlugin>.Info("Building game statistics data...");
var gameData = new GameStatsData
{
Token = config.ApiToken!,
Secret = config.Secret,
GameInfo = BuildGameInfo(),
Players = new List<PlayerStatsData>(),
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<TownOfUsPlugin>.Error($"Error building data for player {record.PlayerName}: {ex.Message}");
}
}
Logger<TownOfUsPlugin>.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<string> ExtractRoleHistory(byte playerId)
{
var roles = new List<string>();
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<NeutralGhostRole>())
{
continue;
}
var roleName = ExtractRoleName(role);
roles.Add(roleName);
}
return roles;
}
private static List<string> ExtractModifiers(byte playerId)
{
var modifiers = new List<string>();
var player = PlayerControl.AllPlayerControls.FirstOrDefault(x => x.PlayerId == playerId);
if (player == null) return modifiers;
var playerModifiers = player.GetModifiers<GameModifier>()
.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, @"<color=#[A-Fa-f0-9]+>", "");
text = text.Replace("</color>", "");
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
/// <summary>
/// Orchestrates the export of game statistics to API and/or local storage.
/// </summary>
public static class GameStatsExporter
{
/// <summary>
/// Exports game data in the background (fire-and-forget).
/// </summary>
public static Task ExportGameDataBackground();
/// <summary>
/// Main export method - reads config, builds data, sends to API.
/// </summary>
public static async Task ExportGameDataAsync();
/// <summary>
/// Sends game data to configured API endpoint.
/// </summary>
private static async Task SendToApiAsync(GameStatsData data, string endpoint);
/// <summary>
/// Saves game data to local JSON file.
/// </summary>
private static async Task SaveLocalBackupAsync(GameStatsData data);
/// <summary>
/// Validates that required data exists for export.
/// </summary>
private static bool ValidateExportData();
}
Full Implementation:
public static Task ExportGameDataBackground()
{
// Fire-and-forget - don't await
_ = Task.Run(async () =>
{
try
{
await ExportGameDataAsync();
}
catch (Exception ex)
{
Logger<TownOfUsPlugin>.Error($"Unhandled error in game stats export: {ex}");
}
});
return Task.CompletedTask;
}
public static async Task ExportGameDataAsync()
{
try
{
Logger<TownOfUsPlugin>.Info("=== Game Stats Export Started ===");
// Validate we have data
if (!ValidateExportData())
{
Logger<TownOfUsPlugin>.Warning("No player data available for export");
return;
}
// Read configuration
var config = await ApiConfigManager.ReadConfigAsync();
if (!config.EnableApiExport)
{
Logger<TownOfUsPlugin>.Info("API export is disabled - skipping");
return;
}
if (!config.IsValid())
{
Logger<TownOfUsPlugin>.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<TownOfUsPlugin>.Info("=== Game Stats Export Completed Successfully ===");
}
catch (Exception ex)
{
Logger<TownOfUsPlugin>.Error($"Error during game stats export: {ex}");
}
}
private static async Task SendToApiAsync(GameStatsData data, string endpoint)
{
try
{
Logger<TownOfUsPlugin>.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<TownOfUsPlugin>.Info($"API response: {responseContent}");
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
Logger<TownOfUsPlugin>.Error($"API returned error: {response.StatusCode} - {errorContent}");
}
}
catch (HttpRequestException httpEx)
{
Logger<TownOfUsPlugin>.Error($"HTTP error sending to API: {httpEx.Message}");
}
catch (TaskCanceledException)
{
Logger<TownOfUsPlugin>.Error("API request timeout (30 seconds exceeded)");
}
catch (Exception ex)
{
Logger<TownOfUsPlugin>.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<TownOfUsPlugin>.Info($"Local backup saved: {filePath}");
}
catch (Exception ex)
{
Logger<TownOfUsPlugin>.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: "<color=#EFBF04>Syzyf</color>" │
│ - RoleString: "<color=#...>Medic</color> > ..." │
│ - 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<GameModifier>() │
│ - 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
// Success
Logger<TownOfUsPlugin>.Info("=== Game Stats Export Completed Successfully ===");
Logger<TownOfUsPlugin>.Info($"Built data for {gameData.Players.Count} players");
Logger<TownOfUsPlugin>.Info($"Local backup saved: {filePath}");
// Warnings
Logger<TownOfUsPlugin>.Warning("API export is disabled - skipping");
Logger<TownOfUsPlugin>.Warning("No player data available for export");
Logger<TownOfUsPlugin>.Warning($"Config file created at: {configPath}");
// Errors
Logger<TownOfUsPlugin>.Error($"Error reading config: {ex.Message}");
Logger<TownOfUsPlugin>.Error($"API returned error: {response.StatusCode} - {errorContent}");
Logger<TownOfUsPlugin>.Error($"HTTP error sending to API: {httpEx.Message}");
Logger<TownOfUsPlugin>.Error("API request timeout (30 seconds exceeded)");
Logger<TownOfUsPlugin>.Error($"Failed to save local backup: {ex.Message}");
Logger<TownOfUsPlugin>.Error($"Error building data for player {record.PlayerName}: {ex.Message}");
Performance Optimization
Async Execution Strategy
// 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
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
// In ApiConfigManager
private static readonly HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(30)
};
public static HttpClient GetHttpClient() => HttpClient;
JSON Serialization Options
// 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
// 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
// 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)
// 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
// Utility to validate exported JSON
public static bool ValidateExportedJson(string jsonPath)
{
try
{
var jsonContent = File.ReadAllText(jsonPath);
var data = JsonSerializer.Deserialize<GameStatsData>(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