Files
MiraExporter/Export/DataTransformer.cs
Bartosz Gradzik 03d4673c00 Fix data export formatting and implement robust modifier detection
Major improvements to data cleaning, modifier extraction, and role name handling:

## Data Cleaning & Normalization
- Enhanced StripColorTags() to remove ALL HTML/Unity tags using universal regex
- Added removal of modifier symbols (♥, #, ★, etc.) from player names
- Fixed winningTeam containing HTML tags by applying StripColorTags()
- Result: Clean player names without tags or symbols in exported JSON

## Modifier Detection System
- Implemented three-tier modifier detection:
  1. Symbol extraction from <size=60%>SYMBOL</size> tags (primary)
  2. Reflection-based GetModifiers<GameModifier>() (secondary)
  3. RoleString parsing fallback (tertiary)
- Fixed false positive Underdog detection from friend codes (username#1234)
- Symbols now extracted ONLY from size tags, not entire player name
- Fixed Dictionary duplicate key error (removed \u2665 duplicate of ♥)
- Supported symbols: ♥❤♡ (Lovers), # (Underdog), ★☆ (VIP), @ (Sleuth), etc.

## Role Name Handling
- Added fallback for roles without GetRoleName() method
- Extracts role names from type names: "GrenadierRole" → "Grenadier"
- Removes "Tou" suffix: "EngineerTou" → "Engineer"
- Skips generic "RoleBehaviour" type (not a real role)
- Result: Full role history now populated correctly

## Code Quality
- Added comprehensive logging for debugging modifier/role extraction
- Enhanced error handling with per-player try-catch blocks
- Improved code documentation with inline comments

## Documentation
- Added CLAUDE.md with complete architecture guide
- Documented modifier detection system
- Added data cleaning & normalization section
- Included troubleshooting guide and development workflows

Fixes: #1 (HTML tags in export), #2 (missing role history), #3 (false modifier detection)

🎮 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 21:42:55 +02:00

471 lines
17 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)
{
// 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})";
}
/// <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)
{
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";
}
/// <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;
}
}