7 Commits

Author SHA1 Message Date
0177220d89 Poprawka czasu gry 2025-10-10 23:00:21 +02:00
52ee993c32 podbicie numeru wersji 2025-10-10 21:54:26 +02:00
5930cccfe5 Poprawki w rolach zwyciezcow 2025-10-10 21:52:09 +02:00
ffd05bf9f0 poprawka w gitignore 2025-10-09 00:11:18 +02:00
888bce220e Bump version to 1.0.2
- Fix role name normalization: remove "Tou" suffix from all roles (e.g., TrackerTou -> Tracker)
- Set platform to "Mira" for all players (TOU Mira is Mira-based)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 00:05:18 +02:00
ccbc3bc5c7 Bump version to 1.0.1
🎮 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 21:47:43 +02:00
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
8 changed files with 698 additions and 71 deletions

4
.gitignore vendored
View File

@@ -10,4 +10,6 @@ riderModule.iml
.qodo .qodo
TownOfUs/Properties/ TownOfUs/Properties/
DOC/logs/ DOC/logs/
.fake .fake
nul
.claude/

326
CLAUDE.md Normal file
View File

@@ -0,0 +1,326 @@
# 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.
### Game Duration Tracking
The plugin tracks actual gameplay duration (not lobby time) using `GameStartTimePatch`:
- Patches `ShipStatus.Begin` to capture when the game round starts
- Stores start time as `Time.time` when gameplay begins
- Calculates duration as difference between game end time and start time
- Resets on each game to ensure accurate per-game timing
### 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, GameStartTimePatch)
### Game Start Flow
1. **ShipStatus.Begin** fires (game round starts, gameplay begins)
2. **GameStartTimePatch.OnShipStatusBegin** runs - records `Time.time` as game start time
### Export Flow (Game End)
1. **AmongUsClient.OnGameEnd** fires (game ends)
2. **GameStartTimePatch.OnGameEndPrefix** runs - logs game duration
3. **TOU Mira's patch** runs (High priority) - populates EndGameData.PlayerRecords
4. **EndGameExportPatch.Postfix** runs (Low priority) - starts export
- Checks for Hide & Seek mode (skip if true)
- Fires Task.Run() with `StatsExporter.ExportGameStatsAsync()`
5. **StatsExporter.ExportGameStatsAsync()**
- Reads `ApiSet.ini` config
- Calls reflection bridge methods to collect data
- Transforms data via `DataTransformer.TransformToExportFormat()`
- Uses `GameStartTimePatch.GetGameDuration()` for accurate game time
- Saves local backup if enabled
- POSTs to API via `ApiClient.SendToApiAsync()`
6. **GameStartTimePatch.OnGameEndPostfix** runs - clears game start time for next game
### 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, actual gameplay time from intro end to game end)
│ └── 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)
- to memorize

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models; using TownOfUsStatsExporter.Models;
using TownOfUsStatsExporter.Patches;
using UnityEngine; using UnityEngine;
namespace TownOfUsStatsExporter.Export; namespace TownOfUsStatsExporter.Export;
@@ -72,13 +73,16 @@ public static class DataTransformer
private static GameInfoData BuildGameInfo() 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 return new GameInfoData
{ {
GameId = Guid.NewGuid().ToString(), GameId = Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow, Timestamp = DateTime.UtcNow,
LobbyCode = InnerNet.GameCode.IntToGameName(AmongUsClient.Instance.GameId), LobbyCode = InnerNet.GameCode.IntToGameName(AmongUsClient.Instance.GameId),
GameMode = GameOptionsManager.Instance?.CurrentGameOptions?.GameMode.ToString() ?? "Unknown", GameMode = GameOptionsManager.Instance?.CurrentGameOptions?.GameMode.ToString() ?? "Unknown",
Duration = Time.time, Duration = gameDuration,
Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0)), Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0)),
}; };
} }
@@ -92,17 +96,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 +124,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;
@@ -170,27 +199,139 @@ public static class DataTransformer
private static string DetermineWinningTeam(string winningFaction, List<PlayerRecordData> playerRecords) private static string DetermineWinningTeam(string winningFaction, List<PlayerRecordData> playerRecords)
{ {
// Use WinningFaction from GameHistory if available TownOfUsStatsPlugin.Logger.LogInfo($"DetermineWinningTeam called with winningFaction='{winningFaction}'");
if (!string.IsNullOrEmpty(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)
{ {
return winningFaction; TownOfUsStatsPlugin.Logger.LogWarning("No winners found in PlayerRecords!");
return "Unknown";
} }
// Fallback: Check first winner's team TownOfUsStatsPlugin.Logger.LogInfo($"Found {winners.Count} winner(s)");
var winner = playerRecords.FirstOrDefault(r => r.Winner);
if (winner == null) // 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"; return "Unknown";
} }
return winner.TeamString switch // Strip color tags first
var cleanString = StripColorTags(roleString);
// Remove everything after " |" (status info)
var pipeIndex = cleanString.IndexOf(" |");
if (pipeIndex > 0)
{ {
"Crewmate" => "Crewmates", cleanString = cleanString.Substring(0, pipeIndex).Trim();
"Impostor" => "Impostors", }
"Neutral" => "Neutrals",
"Custom" => "Custom", // Remove task info like "(0/4)" at the end
_ => "Unknown", 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) private static string GetMapName(byte mapId)
@@ -198,6 +339,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 +414,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();
} }
@@ -222,38 +441,8 @@ public static class DataTransformer
private static string GetPlayerPlatform(PlayerControl? player) private static string GetPlayerPlatform(PlayerControl? player)
{ {
if (player?.Data != null) // TOU Mira is Mira-based, so always return "Mira"
{ return "Mira";
// 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> /// <summary>

View File

@@ -38,6 +38,10 @@ public static class StatsExporter
return; return;
} }
// Wait a short delay to ensure TOU Mira has finished updating all static fields
// (including WinningFaction which is set after PlayerRecords)
await Task.Delay(100);
// Get data from TOU Mira via reflection // Get data from TOU Mira via reflection
var bridge = ReflectionBridgeProvider.GetBridge(); var bridge = ReflectionBridgeProvider.GetBridge();
@@ -54,6 +58,7 @@ public static class StatsExporter
var winningFaction = bridge.GetWinningFaction(); var winningFaction = bridge.GetWinningFaction();
TownOfUsStatsPlugin.Logger.LogInfo($"Collected data: {playerRecords.Count} players, {playerStats.Count} stats entries"); TownOfUsStatsPlugin.Logger.LogInfo($"Collected data: {playerRecords.Count} players, {playerStats.Count} stats entries");
TownOfUsStatsPlugin.Logger.LogInfo($"WinningFaction from GameHistory: '{winningFaction}'");
// Transform to export format // Transform to export format
var gameData = DataTransformer.TransformToExportFormat( var gameData = DataTransformer.TransformToExportFormat(

View File

@@ -0,0 +1,79 @@
using HarmonyLib;
using UnityEngine;
namespace TownOfUsStatsExporter.Patches;
/// <summary>
/// Tracks when the game actually starts (after intro cutscene ends).
/// This allows calculating actual game duration instead of lobby time.
/// </summary>
[HarmonyPatch]
public static class GameStartTimePatch
{
/// <summary>
/// Time when the game started (after intro cutscene), in Unity Time.time format.
/// Null if game hasn't started yet.
/// </summary>
public static float? GameStartTime { get; private set; }
/// <summary>
/// Duration of the last completed game in seconds.
/// Set when the game ends, persists until next game starts.
/// </summary>
public static float LastGameDuration { get; private set; }
/// <summary>
/// Gets the duration of the last completed game in seconds.
/// This value is set when the game ends and remains available for async export.
/// </summary>
public static float GetGameDuration()
{
return LastGameDuration;
}
/// <summary>
/// Patch on ShipStatus.Begin - called when the game round starts.
/// This is more reliable than IntroCutscene.OnDestroy as it's always called when gameplay begins.
/// </summary>
[HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.Begin))]
[HarmonyPostfix]
public static void OnShipStatusBegin()
{
GameStartTime = Time.time;
LastGameDuration = 0f; // Reset duration for new game
TownOfUsStatsPlugin.Logger.LogInfo($"Game started at Time.time = {GameStartTime.Value:F2}");
}
/// <summary>
/// Calculate and store game duration when game ends.
/// This runs before the export, so duration is available for async export.
/// </summary>
[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))]
[HarmonyPrefix]
public static void OnGameEndPrefix()
{
if (GameStartTime.HasValue)
{
LastGameDuration = Time.time - GameStartTime.Value;
TownOfUsStatsPlugin.Logger.LogInfo($"Game ended. Duration: {LastGameDuration:F2} seconds ({LastGameDuration / 60:F2} minutes)");
}
else
{
LastGameDuration = 0f;
TownOfUsStatsPlugin.Logger.LogWarning("Game ended but GameStartTime was not set! Duration will be 0.");
}
}
/// <summary>
/// Clear game start time after game ends to prepare for next game.
/// LastGameDuration is preserved for async export.
/// </summary>
[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))]
[HarmonyPostfix]
[HarmonyPriority(Priority.Last)]
public static void OnGameEndPostfix()
{
GameStartTime = null;
TownOfUsStatsPlugin.Logger.LogInfo("Game start time cleared (duration preserved for export)");
}
}

View File

@@ -317,18 +317,45 @@ 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 if present (e.g., "EngineerTou" -> "Engineer", "TrackerTou" -> "Tracker")
if (roleName != null && roleName.EndsWith("Tou"))
{
roleName = roleName.Substring(0, roleName.Length - 3);
}
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 +576,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();
} }

View File

@@ -8,7 +8,7 @@
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<Description>Town Of Us Stats Exporter - Standalone plugin for exporting game statistics</Description> <Description>Town Of Us Stats Exporter - Standalone plugin for exporting game statistics</Description>
<RootNamespace>TownOfUsStatsExporter</RootNamespace> <RootNamespace>TownOfUsStatsExporter</RootNamespace>
<Version>1.0.0</Version> <Version>1.0.4</Version>
<Authors>ToU Mira Team</Authors> <Authors>ToU Mira Team</Authors>
</PropertyGroup> </PropertyGroup>

View File

@@ -32,7 +32,7 @@ public class TownOfUsStatsPlugin : BasePlugin
/// <summary> /// <summary>
/// Plugin version. /// Plugin version.
/// </summary> /// </summary>
public const string PluginVersion = "1.0.0"; public const string PluginVersion = "1.0.4";
/// <summary> /// <summary>
/// Logger instance for the plugin. /// Logger instance for the plugin.