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); TownOfUsStatsPlugin.Logger.LogInfo($"Transforming player {record.PlayerId} ({record.PlayerName})"); TownOfUsStatsPlugin.Logger.LogInfo($" RoleString: '{record.RoleString}'"); // STEP 1: Extract modifiers from player name BEFORE cleaning it // Symbols like ♥ indicate Lovers modifier var nameModifiers = ExtractModifiersFromPlayerName(record.PlayerName); // Get role history for this player var roles = roleHistory.GetValueOrDefault(record.PlayerId, new List()); TownOfUsStatsPlugin.Logger.LogInfo($" RoleHistory entries: {roles.Count}"); // If roleHistory is empty, try parsing from RoleString as fallback if (roles.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) { TownOfUsStatsPlugin.Logger.LogInfo($" Parsing roles from RoleString..."); roles = ParseRolesFromRoleString(record.RoleString); TownOfUsStatsPlugin.Logger.LogInfo($" Parsed {roles.Count} role(s): {string.Join(", ", roles)}"); } var lastRole = roles.LastOrDefault() ?? "Unknown"; TownOfUsStatsPlugin.Logger.LogInfo($" Final role: {lastRole}"); // 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); // STEP 2: Get modifiers from multiple sources var bridge = ReflectionBridgeProvider.GetBridge(); var modifiers = bridge.GetPlayerModifiers(record.PlayerId); TownOfUsStatsPlugin.Logger.LogInfo($" Reflection modifiers: {modifiers.Count}"); // Add modifiers from player name symbols if (nameModifiers.Count > 0) { TownOfUsStatsPlugin.Logger.LogInfo($" Name symbol modifiers: {nameModifiers.Count} ({string.Join(", ", nameModifiers)})"); foreach (var mod in nameModifiers) { if (!modifiers.Contains(mod)) { modifiers.Add(mod); } } } // If still no modifiers, try parsing from RoleString if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) { TownOfUsStatsPlugin.Logger.LogInfo($" Parsing modifiers from RoleString..."); var roleStringMods = ParseModifiersFromRoleString(record.RoleString); TownOfUsStatsPlugin.Logger.LogInfo($" Parsed {roleStringMods.Count} modifier(s): {string.Join(", ", roleStringMods)}"); modifiers.AddRange(roleStringMods); } TownOfUsStatsPlugin.Logger.LogInfo($" Total modifiers: {modifiers.Count} ({string.Join(", ", modifiers)})"); // 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) { TownOfUsStatsPlugin.Logger.LogInfo($"DetermineWinningTeam called with winningFaction='{winningFaction}'"); // ALWAYS use PlayerRecords to determine the winning team // WinningFaction field can be stale (contains data from previous game) var winners = playerRecords.Where(r => r.Winner).ToList(); if (winners.Count == 0) { TownOfUsStatsPlugin.Logger.LogWarning("No winners found in PlayerRecords!"); return "Unknown"; } TownOfUsStatsPlugin.Logger.LogInfo($"Found {winners.Count} winner(s)"); // Group winners by team var winnersByTeam = winners.GroupBy(w => w.TeamString).ToList(); // Log all winner teams foreach (var group in winnersByTeam) { var roles = string.Join(", ", group.Select(w => ParseFirstRoleFromRoleString(w.RoleString))); TownOfUsStatsPlugin.Logger.LogInfo($" Team '{group.Key}': {group.Count()} winner(s) - Roles: {roles}"); } // Case 1: All winners are from the same team if (winnersByTeam.Count == 1) { var teamString = winnersByTeam[0].Key; TownOfUsStatsPlugin.Logger.LogInfo($"All winners from same team: '{teamString}'"); // For Custom team (solo neutrals, Lovers, etc.), use the role name if (teamString == "Custom") { // If there's only one winner, it's a solo neutral (Werewolf, Glitch, etc.) if (winners.Count == 1) { var roleName = ParseFirstRoleFromRoleString(winners[0].RoleString); TownOfUsStatsPlugin.Logger.LogInfo($"Solo neutral win: '{roleName}'"); return roleName; } // If multiple winners with Custom team, it's Lovers else { TownOfUsStatsPlugin.Logger.LogInfo("Multiple Custom team winners: Lovers"); return "Lovers"; } } // Standard team conversions var result = teamString switch { "Crewmate" => "Crewmates", "Impostor" => "Impostors", "Neutral" => "Neutrals", _ => "Unknown", }; TownOfUsStatsPlugin.Logger.LogInfo($"Determined winning team: '{result}'"); return result; } // Case 2: Mixed teams (e.g., Mercenary winning with Impostors, Jester with Crewmates) // Find the main team (non-Neutral with most winners) TownOfUsStatsPlugin.Logger.LogInfo("Mixed teams detected"); var mainTeam = winnersByTeam .Where(g => g.Key is "Crewmate" or "Impostor") // Only Crewmate or Impostor .OrderByDescending(g => g.Count()) .FirstOrDefault(); if (mainTeam != null) { var result = mainTeam.Key switch { "Crewmate" => "Crewmates", "Impostor" => "Impostors", _ => "Unknown", }; TownOfUsStatsPlugin.Logger.LogInfo($"Main winning team (mixed): '{result}'"); return result; } // Case 3: Only Neutrals won (shouldn't happen, but fallback) TownOfUsStatsPlugin.Logger.LogWarning("Only Neutral/Custom winners, no main team found"); return "Neutrals"; } /// /// Parses the first role name from RoleString (e.g., "Werewolf (Flash) (2/4) | Alive" → "Werewolf") /// private static string ParseFirstRoleFromRoleString(string roleString) { if (string.IsNullOrEmpty(roleString)) { return "Unknown"; } // 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(); // Remove modifiers in parentheses (e.g., "(Flash)") var parenIndex = cleanString.IndexOf('('); if (parenIndex > 0) { cleanString = cleanString.Substring(0, parenIndex).Trim(); } // If there's role history (separated by " > "), get the LAST role if (cleanString.Contains(" > ")) { var roles = cleanString.Split(new[] { " > " }, StringSplitOptions.RemoveEmptyEntries); cleanString = roles.Last().Trim(); // Remove modifiers again (after extracting last role) parenIndex = cleanString.IndexOf('('); if (parenIndex > 0) { cleanString = cleanString.Substring(0, parenIndex).Trim(); } } return string.IsNullOrEmpty(cleanString) ? "Unknown" : cleanString; } private static string GetMapName(byte mapId) { return MapNames.TryGetValue(mapId, out var name) ? name : $"Unknown Map ({mapId})"; } /// /// Extracts modifier information from special symbols in player names. /// Symbols like ♥ (Lovers) appear inside HTML tags like: /// /// Raw player name with HTML tags /// List of modifier names found in the name private static List ExtractModifiersFromPlayerName(string playerName) { var modifiers = new List(); if (string.IsNullOrEmpty(playerName)) { return modifiers; } // Map of symbols to modifier names // Note: \u2665 IS the same as ♥, so we only include one var symbolToModifier = new Dictionary { { "♥", "Lovers" }, // \u2665 in Unicode { "❤", "Lovers" }, // \u2764 { "♡", "Lovers" }, // \u2661 { "#", "Underdog" }, { "★", "VIP" }, // \u2605 { "☆", "VIP" }, // \u2606 { "@", "Sleuth" }, { "†", "Bait" }, // \u2020 { "‡", "Bait" }, // \u2021 { "§", "Torch" }, { "⚡", "Flash" }, { "⚔", "Assassin" }, { "🗡", "Assassin" }, }; // Extract content from size tags: SYMBOL // This is where modifier symbols are located var sizeTagPattern = @"]*>([^<]+)"; var matches = Regex.Matches(playerName, sizeTagPattern); foreach (Match match in matches) { if (match.Groups.Count > 1) { var symbolText = match.Groups[1].Value; TownOfUsStatsPlugin.Logger.LogInfo($" Found symbol in size tag: '{symbolText}'"); // Check if this symbol matches any known modifier foreach (var kvp in symbolToModifier) { if (symbolText.Contains(kvp.Key)) { if (!modifiers.Contains(kvp.Value)) { modifiers.Add(kvp.Value); TownOfUsStatsPlugin.Logger.LogInfo($" Matched modifier '{kvp.Value}' from symbol '{kvp.Key}'"); } } } } } return modifiers; } /// /// Strips all HTML/Unity tags AND modifier symbols from text to get clean player name. /// Should be called AFTER extracting modifiers from symbols. /// private static string StripColorTags(string text) { if (string.IsNullOrEmpty(text)) { return text; } // Step 1: Remove all Unity/HTML tags // Pattern matches: , , , text = Regex.Replace(text, @"<[^>]+>", string.Empty); // Step 2: Remove all modifier symbols that were extracted // These symbols should not appear in the final clean name var symbolsToRemove = new[] { "♥", "❤", "♡", "#", "★", "☆", "@", "†", "‡", "§", "⚡", "⚔", "🗡" }; foreach (var symbol in symbolsToRemove) { text = text.Replace(symbol, 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) { // TOU Mira is Mira-based, so always return "Mira" return "Mira"; } /// /// 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; } }