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>
This commit is contained in:
2025-10-08 21:42:55 +02:00
parent 1682f5bb60
commit 03d4673c00
3 changed files with 467 additions and 23 deletions

View File

@@ -92,17 +92,27 @@ public static class DataTransformer
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($"RoleHistory empty for player {record.PlayerId}, parsing from RoleString: {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());
@@ -110,20 +120,35 @@ public static class DataTransformer
// Count kills
var kills = killedPlayers.Count(k => k.KillerId == record.PlayerId && k.VictimId != record.PlayerId);
// Get modifiers
// STEP 2: Get modifiers from multiple sources
var bridge = ReflectionBridgeProvider.GetBridge();
var modifiers = bridge.GetPlayerModifiers(record.PlayerId);
// If no modifiers found via reflection, try parsing from RoleString
if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString))
TownOfUsStatsPlugin.Logger.LogInfo($" Reflection modifiers: {modifiers.Count}");
// Add modifiers from player name symbols
if (nameModifiers.Count > 0)
{
modifiers = ParseModifiersFromRoleString(record.RoleString);
if (modifiers.Count > 0)
TownOfUsStatsPlugin.Logger.LogInfo($" Name symbol modifiers: {nameModifiers.Count} ({string.Join(", ", nameModifiers)})");
foreach (var mod in nameModifiers)
{
TownOfUsStatsPlugin.Logger.LogInfo($"Parsed {modifiers.Count} modifier(s) from RoleString for player {record.PlayerId}");
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;
@@ -173,7 +198,8 @@ public static class DataTransformer
// Use WinningFaction from GameHistory if available
if (!string.IsNullOrEmpty(winningFaction))
{
return winningFaction;
// Clean HTML tags from winning faction
return StripColorTags(winningFaction);
}
// Fallback: Check first winner's team
@@ -198,6 +224,74 @@ public static class DataTransformer
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))
@@ -205,8 +299,18 @@ public static class DataTransformer
return text;
}
text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", string.Empty);
text = text.Replace("</color>", string.Empty);
// 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();
}