1164 lines
37 KiB
Markdown
1164 lines
37 KiB
Markdown
# 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
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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
|
|
|
|
```csharp
|
|
/// <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)
|
|
|
|
```csharp
|
|
/// <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:**
|
|
|
|
```csharp
|
|
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`
|
|
|
|
```csharp
|
|
/// <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:**
|
|
|
|
```csharp
|
|
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`
|
|
|
|
```csharp
|
|
/// <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:**
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```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<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**
|