Files
MiraExporter/Reflection/TouMiraReflectionBridge.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

589 lines
20 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
namespace TownOfUsStatsExporter.Reflection;
/// <summary>
/// Main bridge for accessing TOU Mira data through reflection.
/// Caches all reflection metadata for performance.
/// </summary>
public class TouMiraReflectionBridge
{
private Assembly? touAssembly;
private readonly ReflectionCache cache = new();
/// <summary>
/// Gets the TOU Mira version.
/// </summary>
public string? TouMiraVersion { get; private set; }
/// <summary>
/// Gets the compatibility status message.
/// </summary>
public string CompatibilityStatus { get; private set; } = "Unknown";
/// <summary>
/// Initialize the reflection bridge by finding TOU Mira and caching reflection metadata.
/// </summary>
/// <returns>True if initialization was successful.</returns>
public bool Initialize()
{
try
{
TownOfUsStatsPlugin.Logger.LogInfo("Initializing TOU Mira reflection bridge...");
// Find TOU Mira assembly - try multiple possible names
var possibleNames = new[] { "TownOfUs", "TownOfUsMira", "TownOfUs.dll" };
foreach (var name in possibleNames)
{
touAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == name || a.GetName().Name?.Contains(name) == true);
if (touAssembly != null)
{
TownOfUsStatsPlugin.Logger.LogInfo($"Found TOU Mira assembly: {touAssembly.GetName().Name}");
break;
}
}
if (touAssembly == null)
{
// Log all loaded assemblies for debugging
var allAssemblies = string.Join(", ", AppDomain.CurrentDomain.GetAssemblies()
.Select(a => a.GetName().Name)
.Where(n => n != null && (n.Contains("Town") || n.Contains("Mira")))
.ToArray());
TownOfUsStatsPlugin.Logger.LogError($"TOU Mira assembly not found! Available assemblies with 'Town' or 'Mira': {allAssemblies}");
return false;
}
TouMiraVersion = touAssembly.GetName().Version?.ToString() ?? "Unknown";
TownOfUsStatsPlugin.Logger.LogInfo($"Found TOU Mira assembly v{TouMiraVersion}");
// Check version compatibility
CompatibilityStatus = VersionCompatibility.CheckVersion(TouMiraVersion);
if (CompatibilityStatus.StartsWith("Unsupported"))
{
TownOfUsStatsPlugin.Logger.LogWarning($"Version compatibility: {CompatibilityStatus}");
TownOfUsStatsPlugin.Logger.LogWarning("Plugin may not work correctly!");
}
// Cache reflection metadata
if (!CacheReflectionMetadata())
{
TownOfUsStatsPlugin.Logger.LogError("Failed to cache reflection metadata");
return false;
}
TownOfUsStatsPlugin.Logger.LogInfo("Reflection bridge initialized successfully");
return true;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Failed to initialize reflection bridge: {ex}");
return false;
}
}
private bool CacheReflectionMetadata()
{
try
{
// Find and cache EndGamePatches type
cache.EndGamePatchesType = touAssembly!.GetType("TownOfUs.Patches.EndGamePatches");
if (cache.EndGamePatchesType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Patches.EndGamePatches");
return false;
}
// Find and cache EndGameData nested type
cache.EndGameDataType = cache.EndGamePatchesType.GetNestedType("EndGameData", BindingFlags.Public);
if (cache.EndGameDataType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: EndGameData");
return false;
}
// Find and cache PlayerRecord nested type
cache.PlayerRecordType = cache.EndGameDataType.GetNestedType("PlayerRecord", BindingFlags.Public);
if (cache.PlayerRecordType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: PlayerRecord");
return false;
}
// Cache PlayerRecords property
cache.PlayerRecordsProperty = cache.EndGameDataType.GetProperty(
"PlayerRecords",
BindingFlags.Public | BindingFlags.Static);
if (cache.PlayerRecordsProperty == null)
{
TownOfUsStatsPlugin.Logger.LogError("Property not found: EndGameData.PlayerRecords");
return false;
}
// Find and cache GameHistory type
cache.GameHistoryType = touAssembly.GetType("TownOfUs.Modules.GameHistory");
if (cache.GameHistoryType == null)
{
TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Modules.GameHistory");
return false;
}
// Cache GameHistory fields (they are fields, not properties!)
cache.PlayerStatsField = cache.GameHistoryType.GetField(
"PlayerStats",
BindingFlags.Public | BindingFlags.Static);
cache.RoleHistoryField = cache.GameHistoryType.GetField(
"RoleHistory",
BindingFlags.Public | BindingFlags.Static);
cache.KilledPlayersField = cache.GameHistoryType.GetField(
"KilledPlayers",
BindingFlags.Public | BindingFlags.Static);
cache.WinningFactionField = cache.GameHistoryType.GetField(
"WinningFaction",
BindingFlags.Public | BindingFlags.Static);
if (cache.PlayerStatsField == null || cache.RoleHistoryField == null)
{
TownOfUsStatsPlugin.Logger.LogError("Required GameHistory fields not found");
return false;
}
TownOfUsStatsPlugin.Logger.LogInfo("All required types and properties cached successfully");
return true;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error caching reflection metadata: {ex}");
return false;
}
}
/// <summary>
/// Get player records from EndGameData.
/// </summary>
/// <returns>List of player record data.</returns>
public List<PlayerRecordData> GetPlayerRecords()
{
try
{
TownOfUsStatsPlugin.Logger.LogInfo("Getting player records from EndGameData...");
var playerRecords = cache.PlayerRecordsProperty!.GetValue(null);
if (playerRecords == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("PlayerRecords is null");
return new List<PlayerRecordData>();
}
TownOfUsStatsPlugin.Logger.LogInfo($"PlayerRecords object retrieved: {playerRecords.GetType().Name}");
// Handle IL2CPP list
var recordsList = IL2CPPHelper.ConvertToManagedList(playerRecords);
TownOfUsStatsPlugin.Logger.LogInfo($"Converted to managed list: {recordsList.Count} items");
var result = new List<PlayerRecordData>();
foreach (var record in recordsList)
{
if (record == null)
{
continue;
}
result.Add(new PlayerRecordData
{
PlayerName = GetPropertyValue<string>(record, "PlayerName") ?? "Unknown",
RoleString = GetPropertyValue<string>(record, "RoleString") ?? string.Empty,
Winner = GetPropertyValue<bool>(record, "Winner"),
PlayerId = GetPropertyValue<byte>(record, "PlayerId"),
TeamString = GetTeamString(record),
});
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved {result.Count} player records");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting player records: {ex}");
return new List<PlayerRecordData>();
}
}
/// <summary>
/// Get player statistics from GameHistory.
/// </summary>
/// <returns>Dictionary of player stats keyed by player ID.</returns>
public Dictionary<byte, PlayerStatsData> GetPlayerStats()
{
try
{
var playerStats = cache.PlayerStatsField!.GetValue(null);
if (playerStats == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("PlayerStats is null");
return new Dictionary<byte, PlayerStatsData>();
}
var statsDict = (IDictionary)playerStats;
var result = new Dictionary<byte, PlayerStatsData>();
foreach (DictionaryEntry entry in statsDict)
{
var playerId = (byte)entry.Key;
var stats = entry.Value;
if (stats == null)
{
continue;
}
result[playerId] = new PlayerStatsData
{
CorrectKills = GetPropertyValue<int>(stats, "CorrectKills"),
IncorrectKills = GetPropertyValue<int>(stats, "IncorrectKills"),
CorrectAssassinKills = GetPropertyValue<int>(stats, "CorrectAssassinKills"),
IncorrectAssassinKills = GetPropertyValue<int>(stats, "IncorrectAssassinKills"),
};
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved stats for {result.Count} players");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting player stats: {ex}");
return new Dictionary<byte, PlayerStatsData>();
}
}
/// <summary>
/// Get role history from GameHistory.
/// </summary>
/// <returns>Dictionary of role lists keyed by player ID.</returns>
public Dictionary<byte, List<string>> GetRoleHistory()
{
try
{
TownOfUsStatsPlugin.Logger.LogInfo("Getting role history from GameHistory...");
var roleHistory = cache.RoleHistoryField!.GetValue(null);
if (roleHistory == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("RoleHistory is null");
return new Dictionary<byte, List<string>>();
}
var historyList = IL2CPPHelper.ConvertToManagedList(roleHistory);
TownOfUsStatsPlugin.Logger.LogInfo($"RoleHistory has {historyList.Count} entries");
var result = new Dictionary<byte, List<string>>();
foreach (var entry in historyList)
{
if (entry == null)
{
TownOfUsStatsPlugin.Logger.LogWarning("Null entry in RoleHistory");
continue;
}
// Entry is KeyValuePair<byte, RoleBehaviour>
var kvpType = entry.GetType();
TownOfUsStatsPlugin.Logger.LogInfo($"Entry type: {kvpType.Name}");
var keyProp = kvpType.GetProperty("Key");
var valueProp = kvpType.GetProperty("Value");
if (keyProp == null || valueProp == null)
{
TownOfUsStatsPlugin.Logger.LogError($"Could not find Key or Value properties on {kvpType.Name}");
continue;
}
var playerId = (byte)keyProp.GetValue(entry)!;
var roleBehaviour = valueProp.GetValue(entry);
if (roleBehaviour == null)
{
TownOfUsStatsPlugin.Logger.LogWarning($"Null RoleBehaviour for player {playerId}");
continue;
}
TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: RoleBehaviour type = {roleBehaviour.GetType().Name}");
// Get role name from RoleBehaviour.GetRoleName() or fallback to type name
string? roleName = null;
var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName");
if (getRoleNameMethod != null)
{
roleName = getRoleNameMethod.Invoke(roleBehaviour, null) as string;
}
// Fallback: Extract role name from type name (e.g., "GrenadierRole" -> "Grenadier")
if (string.IsNullOrEmpty(roleName))
{
var typeName = roleBehaviour.GetType().Name;
// Remove "Role" suffix if present
if (typeName.EndsWith("Role"))
{
roleName = typeName.Substring(0, typeName.Length - 4);
}
// Remove "Tou" suffix (e.g., "EngineerTou" -> "Engineer")
else if (typeName.EndsWith("Tou"))
{
roleName = typeName.Substring(0, typeName.Length - 3);
}
else
{
roleName = typeName;
}
TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: Using type name as role: {roleName}");
}
if (string.IsNullOrEmpty(roleName))
{
TownOfUsStatsPlugin.Logger.LogWarning($"Could not determine role name for player {playerId}");
continue;
}
// Skip generic RoleBehaviour (not a real role)
if (roleName == "RoleBehaviour")
{
TownOfUsStatsPlugin.Logger.LogInfo($"Skipping generic RoleBehaviour for player {playerId}");
continue;
}
TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: Role = {roleName}");
// Skip ghost roles
if (roleName.Contains("Ghost"))
{
TownOfUsStatsPlugin.Logger.LogInfo($"Skipping ghost role: {roleName}");
continue;
}
// Strip color tags
roleName = StripColorTags(roleName);
if (!result.ContainsKey(playerId))
{
result[playerId] = new List<string>();
}
result[playerId].Add(roleName);
}
TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved role history for {result.Count} players");
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting role history: {ex}");
return new Dictionary<byte, List<string>>();
}
}
/// <summary>
/// Get killed players list.
/// </summary>
/// <returns>List of killed player data.</returns>
public List<KilledPlayerData> GetKilledPlayers()
{
try
{
var killedPlayers = cache.KilledPlayersField?.GetValue(null);
if (killedPlayers == null)
{
return new List<KilledPlayerData>();
}
var killedList = IL2CPPHelper.ConvertToManagedList(killedPlayers);
var result = new List<KilledPlayerData>();
foreach (var killed in killedList)
{
if (killed == null)
{
continue;
}
result.Add(new KilledPlayerData
{
KillerId = GetPropertyValue<byte>(killed, "KillerId"),
VictimId = GetPropertyValue<byte>(killed, "VictimId"),
});
}
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting killed players: {ex}");
return new List<KilledPlayerData>();
}
}
/// <summary>
/// Get winning faction string.
/// </summary>
/// <returns>The winning faction name.</returns>
public string GetWinningFaction()
{
try
{
if (cache.WinningFactionField == null)
{
return string.Empty;
}
var winningFaction = cache.WinningFactionField.GetValue(null);
return winningFaction as string ?? string.Empty;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting winning faction: {ex}");
return string.Empty;
}
}
/// <summary>
/// Get modifiers for a player.
/// </summary>
/// <param name="playerId">The player ID.</param>
/// <returns>List of modifier names.</returns>
public List<string> GetPlayerModifiers(byte playerId)
{
try
{
// Find PlayerControl
var player = PlayerControl.AllPlayerControls.ToArray()
.FirstOrDefault(p => p.PlayerId == playerId);
if (player == null)
{
return new List<string>();
}
// Get modifiers through reflection
var getModifiersMethod = player.GetType().GetMethods()
.FirstOrDefault(m => m.Name == "GetModifiers" && m.IsGenericMethod);
if (getModifiersMethod == null)
{
return new List<string>();
}
// Find GameModifier type
var gameModifierType = touAssembly!.GetType("MiraAPI.Modifiers.GameModifier");
if (gameModifierType == null)
{
return new List<string>();
}
var genericMethod = getModifiersMethod.MakeGenericMethod(gameModifierType);
var modifiers = genericMethod.Invoke(player, null);
if (modifiers == null)
{
return new List<string>();
}
var modifiersList = IL2CPPHelper.ConvertToManagedList(modifiers);
var result = new List<string>();
foreach (var modifier in modifiersList)
{
if (modifier == null)
{
continue;
}
var modifierName = GetPropertyValue<string>(modifier, "ModifierName");
if (!string.IsNullOrEmpty(modifierName))
{
result.Add(modifierName);
}
}
return result;
}
catch (Exception ex)
{
TownOfUsStatsPlugin.Logger.LogError($"Error getting modifiers for player {playerId}: {ex}");
return new List<string>();
}
}
private string GetTeamString(object record)
{
try
{
// Get Team property (ModdedRoleTeams enum)
var teamProperty = record.GetType().GetProperty("Team");
if (teamProperty == null)
{
return "Unknown";
}
var team = teamProperty.GetValue(record);
if (team == null)
{
return "Unknown";
}
return team.ToString() ?? "Unknown";
}
catch
{
return "Unknown";
}
}
private T GetPropertyValue<T>(object obj, string propertyName)
{
try
{
var property = obj?.GetType().GetProperty(propertyName);
if (property == null)
{
return default!;
}
var value = property.GetValue(obj);
if (value == null)
{
return default!;
}
return (T)value;
}
catch
{
return default!;
}
}
private string StripColorTags(string text)
{
if (string.IsNullOrEmpty(text))
{
return text;
}
// Remove all Unity/HTML tags
// Pattern matches: <tag>, <tag=value>, <tag=#value>, </tag>
text = Regex.Replace(text, @"<[^>]+>", string.Empty);
return text.Trim();
}
}