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:
311
CLAUDE.md
Normal file
311
CLAUDE.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**TownOfUs Stats Exporter** is a standalone BepInEx plugin for Among Us that exports Town of Us Mira game statistics to a cloud API. The plugin operates completely independently of TOU Mira using reflection and Harmony patches - requiring zero modifications to the main mod.
|
||||||
|
|
||||||
|
## Key Architecture Principles
|
||||||
|
|
||||||
|
### Reflection-Based Approach
|
||||||
|
The plugin **never directly references TOU Mira types**. All access is via reflection through `TouMiraReflectionBridge.cs` which:
|
||||||
|
- Caches all Type and PropertyInfo objects at initialization for performance
|
||||||
|
- Handles IL2CPP interop for Unity collections
|
||||||
|
- Provides version compatibility checking
|
||||||
|
- Falls back gracefully if TOU Mira structures change
|
||||||
|
|
||||||
|
### Harmony Patching Strategy
|
||||||
|
Uses `[HarmonyPriority(Priority.Low)]` on the `AmongUsClient.OnGameEnd` patch to ensure TOU Mira's `BuildEndGameData()` runs first and populates `EndGameData.PlayerRecords` before the export begins.
|
||||||
|
|
||||||
|
### Async Export Pattern
|
||||||
|
Export runs in a fire-and-forget Task.Run() to avoid blocking the game UI. The entire export process (reflection, data transformation, API call) happens asynchronously.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the plugin
|
||||||
|
dotnet build -c Release
|
||||||
|
|
||||||
|
# Build in debug mode
|
||||||
|
dotnet build -c Debug
|
||||||
|
|
||||||
|
# Clean build
|
||||||
|
dotnet clean && dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled DLL will be in `bin/Release/TownOfUsStatsExporter.dll` or `bin/Debug/TownOfUsStatsExporter.dll`.
|
||||||
|
|
||||||
|
If the `AmongUs` environment variable is set, the DLL auto-copies to `$(AmongUs)/BepInEx/plugins/` after build.
|
||||||
|
|
||||||
|
## Testing & Debugging
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
1. Build in Debug mode
|
||||||
|
2. Copy DLL to `Among Us/BepInEx/plugins/`
|
||||||
|
3. Launch Among Us with BepInEx console (F10)
|
||||||
|
4. Check logs for "TownOfUs Stats Exporter" messages
|
||||||
|
5. Complete a game and watch export logs
|
||||||
|
|
||||||
|
### Log Locations
|
||||||
|
- **BepInEx Console**: Real-time logs (press F10 in game)
|
||||||
|
- **BepInEx Log File**: `Among Us/BepInEx/LogOutput.log`
|
||||||
|
- **Local Backups**: `Documents/TownOfUs/GameLogs/Game_YYYYMMDD_HHMMSS_<gameId>.json`
|
||||||
|
|
||||||
|
### Common Debug Scenarios
|
||||||
|
- **"Failed to initialize reflection bridge"**: TOU Mira assembly not found or incompatible version
|
||||||
|
- **"No player data available"**: Export triggered before TOU Mira populated EndGameData
|
||||||
|
- **Empty role history**: RoleString fallback parser activates - check RoleString format
|
||||||
|
|
||||||
|
## Code Structure & Flow
|
||||||
|
|
||||||
|
### Initialization Flow (Plugin Load)
|
||||||
|
1. `TownOfUsStatsPlugin.Load()` - BepInEx entry point
|
||||||
|
2. `TouMiraReflectionBridge.Initialize()` - Find TOU Mira assembly, cache reflection metadata
|
||||||
|
3. `VersionCompatibility.CheckVersion()` - Verify TOU Mira version compatibility
|
||||||
|
4. `Harmony.PatchAll()` - Apply patches (EndGameExportPatch)
|
||||||
|
|
||||||
|
### Export Flow (Game End)
|
||||||
|
1. **AmongUsClient.OnGameEnd** fires (game ends)
|
||||||
|
2. **TOU Mira's patch** runs (High priority) - populates EndGameData.PlayerRecords
|
||||||
|
3. **EndGameExportPatch.Postfix** runs (Low priority) - starts export
|
||||||
|
- Checks for Hide & Seek mode (skip if true)
|
||||||
|
- Fires Task.Run() with `StatsExporter.ExportGameStatsAsync()`
|
||||||
|
4. **StatsExporter.ExportGameStatsAsync()**
|
||||||
|
- Reads `ApiSet.ini` config
|
||||||
|
- Calls reflection bridge methods to collect data
|
||||||
|
- Transforms data via `DataTransformer.TransformToExportFormat()`
|
||||||
|
- Saves local backup if enabled
|
||||||
|
- POSTs to API via `ApiClient.SendToApiAsync()`
|
||||||
|
|
||||||
|
### Key Reflection Patterns
|
||||||
|
|
||||||
|
**Reading TOU Mira Data:**
|
||||||
|
```csharp
|
||||||
|
// Get static property value
|
||||||
|
var playerRecords = cache.PlayerRecordsProperty!.GetValue(null);
|
||||||
|
|
||||||
|
// Get instance property value
|
||||||
|
var value = obj.GetType().GetProperty("PropertyName")?.GetValue(obj);
|
||||||
|
|
||||||
|
// Handle IL2CPP lists
|
||||||
|
var managedList = IL2CPPHelper.ConvertToManagedList(il2cppList);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding New Reflected Data:**
|
||||||
|
1. Add to `ReflectionCache.cs` (Type, PropertyInfo, FieldInfo)
|
||||||
|
2. Cache in `TouMiraReflectionBridge.CacheReflectionMetadata()`
|
||||||
|
3. Add getter method in `TouMiraReflectionBridge`
|
||||||
|
4. Add model to `Models/ReflectedData.cs`
|
||||||
|
5. Use in `DataTransformer.TransformToExportFormat()`
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
### What You Can Access
|
||||||
|
- Public classes, properties, fields, methods in TOU Mira
|
||||||
|
- Static data in `EndGamePatches.EndGameData`
|
||||||
|
- Static data in `GameHistory` (PlayerStats, RoleHistory, KilledPlayers, WinningFaction)
|
||||||
|
- PlayerControl instances via `PlayerControl.AllPlayerControls`
|
||||||
|
|
||||||
|
### What You Cannot Access
|
||||||
|
- Internal or private members (C# access modifiers enforced)
|
||||||
|
- Non-public nested types
|
||||||
|
- Direct type references (must use reflection strings)
|
||||||
|
|
||||||
|
### Version Compatibility
|
||||||
|
- **Tested**: TOU Mira 1.2.0, 1.2.1
|
||||||
|
- **Probably Compatible**: Same major version (1.x.x)
|
||||||
|
- **Incompatible**: Different major version (2.x.x)
|
||||||
|
|
||||||
|
The plugin logs warnings for untested versions but attempts to run anyway.
|
||||||
|
|
||||||
|
## Configuration System
|
||||||
|
|
||||||
|
The plugin searches for `ApiSet.ini` in priority order:
|
||||||
|
1. Game directory (where DLL is located)
|
||||||
|
2. `Documents/TownOfUs/ApiSet.ini`
|
||||||
|
|
||||||
|
If not found, creates a default template at the Documents location.
|
||||||
|
|
||||||
|
**Config Structure (INI format):**
|
||||||
|
```ini
|
||||||
|
EnableApiExport=true
|
||||||
|
ApiToken=your_token_here
|
||||||
|
ApiEndpoint=https://api.example.com/endpoint
|
||||||
|
SaveLocalBackup=true
|
||||||
|
Secret=optional_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is read asynchronously each game - no restart required after editing.
|
||||||
|
|
||||||
|
## Error Handling Philosophy
|
||||||
|
|
||||||
|
### Fail-Safe Design
|
||||||
|
- Plugin failures never crash the game
|
||||||
|
- Export errors are logged but don't interrupt gameplay
|
||||||
|
- Individual player data errors skip that player, continue with others
|
||||||
|
- Missing optional data (modifiers, platform) defaults to empty/Unknown
|
||||||
|
|
||||||
|
### Error Isolation Layers
|
||||||
|
1. **Top-level**: `Task.Run()` catches all exceptions
|
||||||
|
2. **Export-level**: `ExportGameStatsAsync()` catches all exceptions
|
||||||
|
3. **Component-level**: Each transformer/client method has try-catch
|
||||||
|
4. **Player-level**: Each player transform has try-catch
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Export Format (GameStatsData)
|
||||||
|
```
|
||||||
|
GameStatsData
|
||||||
|
├── Token (string) - API auth token
|
||||||
|
├── Secret (string?) - Optional additional secret
|
||||||
|
├── GameInfo
|
||||||
|
│ ├── GameId (guid)
|
||||||
|
│ ├── Timestamp (UTC)
|
||||||
|
│ ├── LobbyCode (e.g., "ABCDEF")
|
||||||
|
│ ├── GameMode (Normal/HideNSeek)
|
||||||
|
│ ├── Duration (seconds)
|
||||||
|
│ └── Map (string)
|
||||||
|
├── Players[] (list)
|
||||||
|
│ ├── PlayerId (byte)
|
||||||
|
│ ├── PlayerName (string)
|
||||||
|
│ ├── PlayerTag (friend code)
|
||||||
|
│ ├── Platform (Steam/Epic/etc)
|
||||||
|
│ ├── Role (final role string)
|
||||||
|
│ ├── Roles[] (full role history, excludes ghost roles)
|
||||||
|
│ ├── Modifiers[] (e.g., ["Button Barry", "Flash"])
|
||||||
|
│ ├── IsWinner (bool)
|
||||||
|
│ └── Stats
|
||||||
|
│ ├── TotalTasks
|
||||||
|
│ ├── TasksCompleted
|
||||||
|
│ ├── Kills
|
||||||
|
│ ├── CorrectKills
|
||||||
|
│ ├── IncorrectKills
|
||||||
|
│ ├── CorrectAssassinKills
|
||||||
|
│ └── IncorrectAssassinKills
|
||||||
|
└── GameResult
|
||||||
|
└── WinningTeam (Crewmates/Impostors/Neutrals)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal Models (ReflectedData)
|
||||||
|
Intermediate models used between reflection bridge and transformer:
|
||||||
|
- `PlayerRecordData` - Raw data from EndGameData.PlayerRecords
|
||||||
|
- `PlayerStatsData` - Kill statistics from GameHistory.PlayerStats
|
||||||
|
- `KilledPlayerData` - Kill events from GameHistory.KilledPlayers
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **BepInEx.Unity.IL2CPP** (6.0.0-be.735) - Plugin framework
|
||||||
|
- **AmongUs.GameLibs.Steam** (2025.9.9) - Game libraries
|
||||||
|
- **HarmonyX** (2.13.0) - Runtime patching
|
||||||
|
- **AllOfUs.MiraAPI** (0.3.0) - MiraAPI types (for PlayerControl, etc)
|
||||||
|
- **Reactor** (2.3.1) - Required by TOU Mira
|
||||||
|
- **Il2CppInterop.Runtime** (1.4.6-ci.426) - IL2CPP interop
|
||||||
|
|
||||||
|
All dependencies are NuGet packages referenced in `TownOfUsStatsExporter.csproj`.
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Reflection is ~100x slower than direct access, but total overhead is <100ms
|
||||||
|
- Reflection metadata is cached at plugin load for fast repeated access
|
||||||
|
- Export runs fully async - zero UI blocking time
|
||||||
|
- HTTP client is reused (static singleton) to avoid connection overhead
|
||||||
|
- JSON serialization uses System.Text.Json (fast, modern)
|
||||||
|
|
||||||
|
## Modifier Detection System
|
||||||
|
|
||||||
|
The plugin uses **three-tier modifier detection**:
|
||||||
|
|
||||||
|
### 1. Symbol Extraction (Primary)
|
||||||
|
Player names can contain special symbols inside HTML tags that indicate modifiers.
|
||||||
|
|
||||||
|
**Format**: `<b><size=60%>SYMBOL</size></b>`
|
||||||
|
|
||||||
|
Supported symbols:
|
||||||
|
- `♥` `❤` `♡` `\u2665` → **Lovers** modifier
|
||||||
|
- `#` → **Underdog** modifier
|
||||||
|
- `★` `☆` → **VIP** modifier
|
||||||
|
- `@` → **Sleuth** modifier
|
||||||
|
- `†` `‡` → **Bait** modifier
|
||||||
|
- `§` → **Torch** modifier
|
||||||
|
- `⚡` → **Flash** modifier
|
||||||
|
- `⚔` `🗡` → **Assassin** modifier
|
||||||
|
|
||||||
|
**Important**:
|
||||||
|
- Symbols are extracted **only from `<size=...>SYMBOL</size>` tags**, not from entire player name
|
||||||
|
- This prevents false positives (e.g., `#` in friend codes like `username#1234`)
|
||||||
|
- Extraction happens **before** HTML tag stripping to preserve modifier information
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Input: "<color=#EFBF04>boracik</color> <b><size=60%>♥</size></b>"
|
||||||
|
Step 1: ExtractModifiersFromPlayerName() finds "♥" in size tag → ["Lovers"]
|
||||||
|
Step 2: StripColorTags() removes HTML tags AND symbols → "boracik"
|
||||||
|
Output: playerName="boracik", modifiers=["Lovers"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `StripColorTags()` function removes both HTML tags AND modifier symbols to ensure clean player names.
|
||||||
|
|
||||||
|
### 2. Reflection (Secondary)
|
||||||
|
Attempts to get modifiers via reflection on `PlayerControl.GetModifiers<GameModifier>()`
|
||||||
|
|
||||||
|
### 3. RoleString Parsing (Fallback)
|
||||||
|
Parses modifier names from parentheses in RoleString if both above methods fail.
|
||||||
|
|
||||||
|
### Processing Order in DataTransformer.TransformPlayerData()
|
||||||
|
```
|
||||||
|
1. ExtractModifiersFromPlayerName(record.PlayerName) → Gets symbols
|
||||||
|
2. bridge.GetPlayerModifiers(playerId) → Gets via reflection
|
||||||
|
3. ParseModifiersFromRoleString(record.RoleString) → Parses string
|
||||||
|
4. StripColorTags(record.PlayerName) → Clean name for export
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Features
|
||||||
|
|
||||||
|
### To Add New Modifier Symbol
|
||||||
|
1. Edit `ExtractModifiersFromPlayerName()` in `Export/DataTransformer.cs`
|
||||||
|
2. Add symbol to `symbolToModifier` dictionary
|
||||||
|
3. Rebuild and test
|
||||||
|
|
||||||
|
### To Add New Exported Data
|
||||||
|
1. Check if TOU Mira exposes it publicly (use dnSpy or ILSpy)
|
||||||
|
2. Add reflection cache entry in `ReflectionCache.cs`
|
||||||
|
3. Add caching logic in `TouMiraReflectionBridge.CacheReflectionMetadata()`
|
||||||
|
4. Add getter method in `TouMiraReflectionBridge`
|
||||||
|
5. Add property to export model in `Models/GameStatsData.cs`
|
||||||
|
6. Add transformation in `DataTransformer.TransformToExportFormat()`
|
||||||
|
|
||||||
|
### To Add New Config Options
|
||||||
|
1. Add property to `ApiConfig.cs`
|
||||||
|
2. Add parsing in `ApiConfigManager.ParseIniFile()`
|
||||||
|
3. Add to default template in `ApiConfigManager.CreateDefaultConfigAsync()`
|
||||||
|
4. Use in appropriate exporter class
|
||||||
|
|
||||||
|
## Data Cleaning & Normalization
|
||||||
|
|
||||||
|
All exported data is cleaned to ensure consistency:
|
||||||
|
|
||||||
|
### Text Cleaning (StripColorTags)
|
||||||
|
Removes from all text fields (playerName, winningTeam, etc.):
|
||||||
|
- HTML/Unity tags: `<color=#XXX>`, `<b>`, `<size=XX%>`, etc.
|
||||||
|
- Modifier symbols: `♥`, `#`, `★`, `@`, etc.
|
||||||
|
|
||||||
|
### Role Name Normalization
|
||||||
|
- Removes suffixes: `"GrenadierRole"` → `"Grenadier"`
|
||||||
|
- Removes TOU suffix: `"EngineerTou"` → `"Engineer"`
|
||||||
|
- Skips generic types: `"RoleBehaviour"` (not added to history)
|
||||||
|
|
||||||
|
### Modifier Symbol Extraction
|
||||||
|
Symbols are extracted **before** text cleaning to preserve information:
|
||||||
|
1. Extract from `<size=60%>SYMBOL</size>` tags
|
||||||
|
2. Map to modifier names (e.g., `♥` → `"Lovers"`)
|
||||||
|
3. Clean text (remove tags and symbols)
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Cannot access internal/private TOU Mira members
|
||||||
|
- Role/modifier parsing falls back to string parsing if reflection fails
|
||||||
|
- Platform detection may fail on some Among Us versions (defaults to "Unknown")
|
||||||
|
- No compile-time type safety (all reflection is string-based)
|
||||||
|
- Breaking changes in TOU Mira require plugin updates
|
||||||
|
- Symbol encoding may vary by console/log viewer (e.g., `♥` may display as `ÔÖą` in logs)
|
||||||
@@ -92,17 +92,27 @@ public static class DataTransformer
|
|||||||
var player = PlayerControl.AllPlayerControls.ToArray()
|
var player = PlayerControl.AllPlayerControls.ToArray()
|
||||||
.FirstOrDefault(p => p.PlayerId == record.PlayerId);
|
.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
|
// Get role history for this player
|
||||||
var roles = roleHistory.GetValueOrDefault(record.PlayerId, new List<string>());
|
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 roleHistory is empty, try parsing from RoleString as fallback
|
||||||
if (roles.Count == 0 && !string.IsNullOrEmpty(record.RoleString))
|
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);
|
roles = ParseRolesFromRoleString(record.RoleString);
|
||||||
|
TownOfUsStatsPlugin.Logger.LogInfo($" Parsed {roles.Count} role(s): {string.Join(", ", roles)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastRole = roles.LastOrDefault() ?? "Unknown";
|
var lastRole = roles.LastOrDefault() ?? "Unknown";
|
||||||
|
TownOfUsStatsPlugin.Logger.LogInfo($" Final role: {lastRole}");
|
||||||
|
|
||||||
// Get stats
|
// Get stats
|
||||||
var stats = playerStats.GetValueOrDefault(record.PlayerId, new PlayerStatsData());
|
var stats = playerStats.GetValueOrDefault(record.PlayerId, new PlayerStatsData());
|
||||||
@@ -110,20 +120,35 @@ public static class DataTransformer
|
|||||||
// Count kills
|
// Count kills
|
||||||
var kills = killedPlayers.Count(k => k.KillerId == record.PlayerId && k.VictimId != record.PlayerId);
|
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 bridge = ReflectionBridgeProvider.GetBridge();
|
||||||
var modifiers = bridge.GetPlayerModifiers(record.PlayerId);
|
var modifiers = bridge.GetPlayerModifiers(record.PlayerId);
|
||||||
|
TownOfUsStatsPlugin.Logger.LogInfo($" Reflection modifiers: {modifiers.Count}");
|
||||||
// If no modifiers found via reflection, try parsing from RoleString
|
|
||||||
if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString))
|
// Add modifiers from player name symbols
|
||||||
|
if (nameModifiers.Count > 0)
|
||||||
{
|
{
|
||||||
modifiers = ParseModifiersFromRoleString(record.RoleString);
|
TownOfUsStatsPlugin.Logger.LogInfo($" Name symbol modifiers: {nameModifiers.Count} ({string.Join(", ", nameModifiers)})");
|
||||||
if (modifiers.Count > 0)
|
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
|
// Get task info
|
||||||
int totalTasks = 0;
|
int totalTasks = 0;
|
||||||
int completedTasks = 0;
|
int completedTasks = 0;
|
||||||
@@ -173,7 +198,8 @@ public static class DataTransformer
|
|||||||
// Use WinningFaction from GameHistory if available
|
// Use WinningFaction from GameHistory if available
|
||||||
if (!string.IsNullOrEmpty(winningFaction))
|
if (!string.IsNullOrEmpty(winningFaction))
|
||||||
{
|
{
|
||||||
return winningFaction;
|
// Clean HTML tags from winning faction
|
||||||
|
return StripColorTags(winningFaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Check first winner's team
|
// 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})";
|
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)
|
private static string StripColorTags(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text))
|
if (string.IsNullOrEmpty(text))
|
||||||
@@ -205,8 +299,18 @@ public static class DataTransformer
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", string.Empty);
|
// Step 1: Remove all Unity/HTML tags
|
||||||
text = text.Replace("</color>", string.Empty);
|
// 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();
|
return text.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,18 +317,48 @@ public class TouMiraReflectionBridge
|
|||||||
|
|
||||||
TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: RoleBehaviour type = {roleBehaviour.GetType().Name}");
|
TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: RoleBehaviour type = {roleBehaviour.GetType().Name}");
|
||||||
|
|
||||||
// Get role name from RoleBehaviour.GetRoleName()
|
// Get role name from RoleBehaviour.GetRoleName() or fallback to type name
|
||||||
|
string? roleName = null;
|
||||||
|
|
||||||
var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName");
|
var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName");
|
||||||
if (getRoleNameMethod == null)
|
if (getRoleNameMethod != null)
|
||||||
{
|
{
|
||||||
TownOfUsStatsPlugin.Logger.LogWarning($"GetRoleName method not found on {roleBehaviour.GetType().Name}");
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var roleName = getRoleNameMethod.Invoke(roleBehaviour, null) as string;
|
// Skip generic RoleBehaviour (not a real role)
|
||||||
if (string.IsNullOrEmpty(roleName))
|
if (roleName == "RoleBehaviour")
|
||||||
{
|
{
|
||||||
TownOfUsStatsPlugin.Logger.LogWarning($"GetRoleName returned null/empty for player {playerId}");
|
TownOfUsStatsPlugin.Logger.LogInfo($"Skipping generic RoleBehaviour for player {playerId}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,10 +579,9 @@ public class TouMiraReflectionBridge
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", string.Empty);
|
// Remove all Unity/HTML tags
|
||||||
text = text.Replace("</color>", string.Empty);
|
// Pattern matches: <tag>, <tag=value>, <tag=#value>, </tag>
|
||||||
text = text.Replace("<b>", string.Empty).Replace("</b>", string.Empty);
|
text = Regex.Replace(text, @"<[^>]+>", string.Empty);
|
||||||
text = text.Replace("<i>", string.Empty).Replace("</i>", string.Empty);
|
|
||||||
|
|
||||||
return text.Trim();
|
return text.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user