556 lines
20 KiB
C#
556 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using TownOfUsStatsExporter.Models;
|
|
using TownOfUsStatsExporter.Patches;
|
|
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()
|
|
{
|
|
// Get actual game duration (from intro end to game end) instead of total Time.time
|
|
var gameDuration = GameStartTimePatch.GetGameDuration();
|
|
|
|
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 = gameDuration,
|
|
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;
|
|
}
|
|
}
|