Files
MiraExporter/Export/DataTransformer.cs

552 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
using UnityEngine;
namespace TownOfUsStatsExporter.Export;
/// <summary>
/// Transforms reflected TOU Mira data into export format.
/// </summary>
public static class DataTransformer
{
private static readonly Dictionary<byte, string> MapNames = new()
{
{ 0, "The Skeld" },
{ 1, "MIRA HQ" },
{ 2, "Polus" },
{ 3, "Airship" },
{ 4, "The Fungle" },
{ 5, "Submerged" },
};
/// <summary>
/// Transforms TOU Mira data to export format.
/// </summary>
/// <param name="playerRecords">Player records from TOU Mira.</param>
/// <param name="playerStats">Player statistics from TOU Mira.</param>
/// <param name="roleHistory">Role history from TOU Mira.</param>
/// <param name="killedPlayers">Killed players list from TOU Mira.</param>
/// <param name="winningFaction">Winning faction name.</param>
/// <param name="apiToken">API authentication token.</param>
/// <param name="secret">Optional secret for authentication.</param>
/// <returns>Game statistics data ready for export.</returns>
public static GameStatsData TransformToExportFormat(
List<PlayerRecordData> playerRecords,
Dictionary<byte, PlayerStatsData> playerStats,
Dictionary<byte, List<string>> roleHistory,
List<KilledPlayerData> 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<byte, PlayerStatsData> playerStats,
Dictionary<byte, List<string>> roleHistory,
List<KilledPlayerData> 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<string>());
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<PlayerRecordData> 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";
}
/// <summary>
/// Parses the first role name from RoleString (e.g., "Werewolf (Flash) (2/4) | Alive" → "Werewolf")
/// </summary>
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})";
}
/// <summary>
/// Extracts modifier information from special symbols in player names.
/// Symbols like ♥ (Lovers) appear inside HTML tags like: <b><size=60%>♥</size></b>
/// </summary>
/// <param name="playerName">Raw player name with HTML tags</param>
/// <returns>List of modifier names found in the name</returns>
private static List<string> ExtractModifiersFromPlayerName(string playerName)
{
var modifiers = new List<string>();
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<string, string>
{
{ "♥", "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: <size=60%>SYMBOL</size>
// This is where modifier symbols are located
var sizeTagPattern = @"<size=[^>]*>([^<]+)</size>";
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;
}
/// <summary>
/// Strips all HTML/Unity tags AND modifier symbols from text to get clean player name.
/// Should be called AFTER extracting modifiers from symbols.
/// </summary>
private static string StripColorTags(string text)
{
if (string.IsNullOrEmpty(text))
{
return text;
}
// Step 1: Remove all Unity/HTML tags
// Pattern matches: <tag>, <tag=value>, <tag=#value>, </tag>
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";
}
/// <summary>
/// Parses roles from RoleString format with color tags and separators.
/// </summary>
private static List<string> ParseRolesFromRoleString(string roleString)
{
var roles = new List<string>();
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;
}
/// <summary>
/// Parses modifiers from RoleString format.
/// Modifiers appear in parentheses after the role name.
/// Example: "Undertaker (Button Barry)" -> ["Button Barry"]
/// </summary>
private static List<string> ParseModifiersFromRoleString(string roleString)
{
var modifiers = new List<string>();
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;
}
}