9 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
1682f5bb60 Merge origin/main: resolve README.md conflict 2025-10-08 01:41:10 +02:00
edde1dde68 Initial commit 2025-10-07 23:35:49 +00:00
9 changed files with 716 additions and 71 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ riderModule.iml
TownOfUs/Properties/
DOC/logs/
.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.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
using TownOfUsStatsExporter.Patches;
using UnityEngine;
namespace TownOfUsStatsExporter.Export;
@@ -72,13 +73,16 @@ public static class DataTransformer
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
{
GameId = Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
LobbyCode = InnerNet.GameCode.IntToGameName(AmongUsClient.Instance.GameId),
GameMode = GameOptionsManager.Instance?.CurrentGameOptions?.GameMode.ToString() ?? "Unknown",
Duration = Time.time,
Duration = gameDuration,
Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0)),
};
}
@@ -92,17 +96,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 +124,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);
TownOfUsStatsPlugin.Logger.LogInfo($" Reflection modifiers: {modifiers.Count}");
// If no modifiers found via reflection, try parsing from RoleString
// 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))
{
modifiers = ParseModifiersFromRoleString(record.RoleString);
if (modifiers.Count > 0)
{
TownOfUsStatsPlugin.Logger.LogInfo($"Parsed {modifiers.Count} modifier(s) from RoleString for player {record.PlayerId}");
}
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;
@@ -170,27 +199,139 @@ public static class DataTransformer
private static string DetermineWinningTeam(string winningFaction, List<PlayerRecordData> playerRecords)
{
// Use WinningFaction from GameHistory if available
if (!string.IsNullOrEmpty(winningFaction))
{
return winningFaction;
}
TownOfUsStatsPlugin.Logger.LogInfo($"DetermineWinningTeam called with winningFaction='{winningFaction}'");
// Fallback: Check first winner's team
var winner = playerRecords.FirstOrDefault(r => r.Winner);
if (winner == null)
// 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)
{
TownOfUsStatsPlugin.Logger.LogWarning("No winners found in PlayerRecords!");
return "Unknown";
}
return winner.TeamString switch
TownOfUsStatsPlugin.Logger.LogInfo($"Found {winners.Count} winner(s)");
// 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",
"Custom" => "Custom",
_ => "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";
}
// 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();
// 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)
@@ -198,6 +339,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 +414,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();
}
@@ -222,38 +441,8 @@ public static class DataTransformer
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";
// TOU Mira is Mira-based, so always return "Mira"
return "Mira";
}
/// <summary>

View File

@@ -38,6 +38,10 @@ public static class StatsExporter
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
var bridge = ReflectionBridgeProvider.GetBridge();
@@ -54,6 +58,7 @@ public static class StatsExporter
var winningFaction = bridge.GetWinningFaction();
TownOfUsStatsPlugin.Logger.LogInfo($"Collected data: {playerRecords.Count} players, {playerStats.Count} stats entries");
TownOfUsStatsPlugin.Logger.LogInfo($"WinningFaction from GameHistory: '{winningFaction}'");
// Transform to export format
var gameData = DataTransformer.TransformToExportFormat(

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2025 boracik
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

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}");
// 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");
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;
}
var roleName = getRoleNameMethod.Invoke(roleBehaviour, null) as string;
if (string.IsNullOrEmpty(roleName))
// Skip generic RoleBehaviour (not a real role)
if (roleName == "RoleBehaviour")
{
TownOfUsStatsPlugin.Logger.LogWarning($"GetRoleName returned null/empty for player {playerId}");
TownOfUsStatsPlugin.Logger.LogInfo($"Skipping generic RoleBehaviour for player {playerId}");
continue;
}
@@ -549,10 +576,9 @@ public class TouMiraReflectionBridge
return text;
}
text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", string.Empty);
text = text.Replace("</color>", string.Empty);
text = text.Replace("<b>", string.Empty).Replace("</b>", string.Empty);
text = text.Replace("<i>", string.Empty).Replace("</i>", string.Empty);
// Remove all Unity/HTML tags
// Pattern matches: <tag>, <tag=value>, <tag=#value>, </tag>
text = Regex.Replace(text, @"<[^>]+>", string.Empty);
return text.Trim();
}

View File

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

View File

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