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) { // Use WinningFaction from GameHistory if available if (!string.IsNullOrEmpty(winningFaction)) { // Clean HTML tags from winning faction return StripColorTags(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})"; } /// /// 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; } }