Files
MiraExporter/DOC/GameStats_Technical_Design.md
2025-10-08 01:39:13 +02:00

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

  1. Class Specifications
  2. Data Flow Diagrams
  3. Error Handling Strategy
  4. Performance Optimization
  5. 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