using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using TownOfUsStatsExporter.Models; using UnityEngine; namespace TownOfUsStatsExporter.Export; /// /// Transforms reflected TOU Mira data into export format. /// public static class DataTransformer { private static readonly Dictionary MapNames = new() { { 0, "The Skeld" }, { 1, "MIRA HQ" }, { 2, "Polus" }, { 3, "Airship" }, { 4, "The Fungle" }, { 5, "Submerged" }, }; /// /// Transforms TOU Mira data to export format. /// /// Player records from TOU Mira. /// Player statistics from TOU Mira. /// Role history from TOU Mira. /// Killed players list from TOU Mira. /// Winning faction name. /// API authentication token. /// Optional secret for authentication. /// Game statistics data ready for export. public static GameStatsData TransformToExportFormat( List playerRecords, Dictionary playerStats, Dictionary> roleHistory, List killedPlayers, string winningFaction, string apiToken, string? secret) { var gameData = new GameStatsData { Token = apiToken, Secret = secret, GameInfo = BuildGameInfo(), GameResult = new GameResultData { WinningTeam = DetermineWinningTeam(winningFaction, playerRecords), }, }; // Transform each player foreach (var record in playerRecords) { try { var playerData = TransformPlayerData(record, playerStats, roleHistory, killedPlayers); gameData.Players.Add(playerData); } catch (Exception ex) { TownOfUsStatsPlugin.Logger.LogError($"Error transforming player {record.PlayerName}: {ex}"); } } 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 PlayerExportData TransformPlayerData( PlayerRecordData record, Dictionary playerStats, Dictionary> roleHistory, List killedPlayers) { var player = PlayerControl.AllPlayerControls.ToArray() .FirstOrDefault(p => p.PlayerId == record.PlayerId); // Get role history for this player var roles = roleHistory.GetValueOrDefault(record.PlayerId, new List()); // If roleHistory is empty, try parsing from RoleString as fallback if (roles.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) { TownOfUsStatsPlugin.Logger.LogInfo($"RoleHistory empty for player {record.PlayerId}, parsing from RoleString: {record.RoleString}"); roles = ParseRolesFromRoleString(record.RoleString); } var lastRole = roles.LastOrDefault() ?? "Unknown"; // Get stats var stats = playerStats.GetValueOrDefault(record.PlayerId, new PlayerStatsData()); // Count kills var kills = killedPlayers.Count(k => k.KillerId == record.PlayerId && k.VictimId != record.PlayerId); // Get modifiers var bridge = ReflectionBridgeProvider.GetBridge(); var modifiers = bridge.GetPlayerModifiers(record.PlayerId); // If no modifiers found via reflection, try parsing from RoleString if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) { modifiers = ParseModifiersFromRoleString(record.RoleString); if (modifiers.Count > 0) { TownOfUsStatsPlugin.Logger.LogInfo($"Parsed {modifiers.Count} modifier(s) from RoleString for player {record.PlayerId}"); } } // Get task info int totalTasks = 0; int completedTasks = 0; if (player != null && player.Data?.Tasks != null) { totalTasks = player.Data.Tasks.ToArray().Length; completedTasks = player.Data.Tasks.ToArray().Count(t => t.Complete); } // Fix assassin kills: negative values mean incorrect guesses // TOU Mira uses CorrectAssassinKills-- when player misguesses, resulting in -1 int correctAssassinKills = stats.CorrectAssassinKills; int incorrectAssassinKills = stats.IncorrectAssassinKills; if (correctAssassinKills < 0) { // Negative correct kills means they misguessed incorrectAssassinKills += Math.Abs(correctAssassinKills); correctAssassinKills = 0; } return new PlayerExportData { PlayerId = record.PlayerId, PlayerName = StripColorTags(record.PlayerName), PlayerTag = GetPlayerTag(player), Platform = GetPlayerPlatform(player), Role = lastRole, Roles = roles, Modifiers = modifiers, IsWinner = record.Winner, Stats = new PlayerStatsNumbers { TotalTasks = totalTasks, TasksCompleted = completedTasks, Kills = kills, CorrectKills = stats.CorrectKills, IncorrectKills = stats.IncorrectKills, CorrectAssassinKills = correctAssassinKills, IncorrectAssassinKills = incorrectAssassinKills, }, }; } private static string DetermineWinningTeam(string winningFaction, List playerRecords) { // Use WinningFaction from GameHistory if available if (!string.IsNullOrEmpty(winningFaction)) { return winningFaction; } // Fallback: Check first winner's team var winner = playerRecords.FirstOrDefault(r => r.Winner); if (winner == null) { return "Unknown"; } return winner.TeamString switch { "Crewmate" => "Crewmates", "Impostor" => "Impostors", "Neutral" => "Neutrals", "Custom" => "Custom", _ => "Unknown", }; } private static string GetMapName(byte mapId) { return MapNames.TryGetValue(mapId, out var name) ? name : $"Unknown Map ({mapId})"; } private static string StripColorTags(string text) { if (string.IsNullOrEmpty(text)) { return text; } text = Regex.Replace(text, @"", string.Empty); text = text.Replace("", string.Empty); return text.Trim(); } private static string? GetPlayerTag(PlayerControl? player) { if (player?.Data?.FriendCode != null && !string.IsNullOrEmpty(player.Data.FriendCode)) { return player.Data.FriendCode; } return null; } private static string GetPlayerPlatform(PlayerControl? player) { if (player?.Data != null) { // Try to get platform info - may not be available in all Among Us versions try { var platformField = player.Data.GetType().GetField("Platform"); if (platformField != null) { var platformValue = platformField.GetValue(player.Data); if (platformValue != null) { return platformValue.ToString() ?? "Unknown"; } } var platformProperty = player.Data.GetType().GetProperty("Platform"); if (platformProperty != null) { var platformValue = platformProperty.GetValue(player.Data); if (platformValue != null) { return platformValue.ToString() ?? "Unknown"; } } } catch { // Platform not available, continue } } return "Unknown"; } /// /// Parses roles from RoleString format with color tags and separators. /// private static List ParseRolesFromRoleString(string roleString) { var roles = new List(); if (string.IsNullOrEmpty(roleString)) { return roles; } // RoleString format: "RoleName (Modifier) (0/4) | Status | Other Info" // We only want the role names before " > " separator // First, split by " > " to get role history var roleParts = roleString.Split(new[] { " > " }, StringSplitOptions.RemoveEmptyEntries); foreach (var part in roleParts) { // Strip color tags first var cleanPart = StripColorTags(part).Trim(); // Extract just the role name before any modifiers or additional info // Format: "RoleName (Modifier) (Tasks) | Other..." // Remove everything after " | " (status info like "Alive", "Killed By", etc.) var pipeIndex = cleanPart.IndexOf(" |"); if (pipeIndex > 0) { cleanPart = cleanPart.Substring(0, pipeIndex).Trim(); } // Remove task info like "(0/4)" at the end cleanPart = Regex.Replace(cleanPart, @"\s*\(\d+/\d+\)\s*$", "").Trim(); // Remove modifier info in parentheses like "(Flash)", "(Button Barry)" // Keep only the first part before parentheses var parenIndex = cleanPart.IndexOf('('); if (parenIndex > 0) { cleanPart = cleanPart.Substring(0, parenIndex).Trim(); } if (!string.IsNullOrEmpty(cleanPart)) { roles.Add(cleanPart); } } return roles; } /// /// Parses modifiers from RoleString format. /// Modifiers appear in parentheses after the role name. /// Example: "Undertaker (Button Barry)" -> ["Button Barry"] /// private static List ParseModifiersFromRoleString(string roleString) { var modifiers = new List(); if (string.IsNullOrEmpty(roleString)) { return modifiers; } // Strip color tags first var cleanString = StripColorTags(roleString); // Remove everything after " | " (status info) var pipeIndex = cleanString.IndexOf(" |"); if (pipeIndex > 0) { cleanString = cleanString.Substring(0, pipeIndex).Trim(); } // Remove task info like "(0/4)" at the end cleanString = Regex.Replace(cleanString, @"\s*\(\d+/\d+\)\s*$", "").Trim(); // Now extract modifiers from parentheses // Pattern: RoleName (Modifier1, Modifier2) or RoleName (Modifier) var modifierPattern = @"\(([^)]+)\)"; var matches = Regex.Matches(cleanString, modifierPattern); foreach (Match match in matches) { if (match.Groups.Count > 1) { var modifierText = match.Groups[1].Value.Trim(); // Split by comma if there are multiple modifiers var modifierNames = modifierText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); foreach (var modName in modifierNames) { var cleanModifier = modName.Trim(); if (!string.IsNullOrEmpty(cleanModifier)) { modifiers.Add(cleanModifier); } } } } return modifiers; } }