25 KiB
Game Statistics API Export - Implementation Plan
Town of Us: Mira Edition
Document Version: 1.0 Date: 2025-10-07 Author: Implementation Analysis
Table of Contents
- Executive Summary
- Current State Analysis
- Reference Implementation Analysis
- Architecture Design
- Implementation Roadmap
- Technical Specifications
- Testing Strategy
- Security Considerations
- Maintenance and Monitoring
Executive Summary
This document outlines the implementation plan for adding game statistics API export functionality to Town of Us: Mira, based on the existing implementation in ToU-stats/EndGamePatch.cs. The system will collect comprehensive game data at the end of each match and optionally send it to a configured API endpoint.
Key Objectives
- Export detailed game statistics including player roles, modifiers, stats, and match results
- Provide optional local backup functionality
- Support secure configuration through external INI file
- Maintain backward compatibility with existing EndGame functionality
- Follow ToU Mira's architecture using MiraAPI patterns
Target JSON Output Format
{
"token": "string",
"secret": "string",
"gameInfo": {
"gameId": "guid",
"timestamp": "ISO8601",
"lobbyCode": "XXXXX",
"gameMode": "Normal|HideNSeek",
"duration": 527.189,
"map": "The Skeld|MIRA HQ|Polus|Airship|The Fungle|Submerged"
},
"players": [
{
"playerId": 0,
"playerName": "string",
"playerTag": "string|null",
"platform": "string",
"role": "string",
"roles": ["string"],
"modifiers": ["string"],
"isWinner": true|false,
"stats": {
"totalTasks": 10,
"tasksCompleted": 8,
"kills": 0,
"correctKills": 0,
"incorrectKills": 0,
"correctAssassinKills": 0,
"incorrectAssassinKills": 0
}
}
],
"gameResult": {
"winningTeam": "Crewmates|Impostors|Neutrals|Unknown"
}
}
Current State Analysis
ToU Mira Architecture
Framework: MiraAPI 0.3.0 + Reactor 2.3.1 + BepInEx IL2CPP 6.0.0
Key Components:
-
EndGamePatches.cs - Current end game handling
BuildEndGameData()- Collects player records intoEndGameData.PlayerRecordsBuildEndGameSummary()- Creates UI summary displayAmongUsClientGameEndPatch()- Harmony patch onAmongUsClient.OnGameEndEndGameDataclass - Stores player records with:- PlayerName (with winner highlighting)
- RoleString (formatted with colors)
- Winner flag
- LastRole (RoleTypes)
- Team (ModdedRoleTeams)
- PlayerId
-
GameHistory.cs - Game state tracking
RoleHistory- List of all role changes per playerKilledPlayers- DeadPlayer records with killer/victim/timePlayerStats- Dictionary of player statistics- CorrectKills, IncorrectKills
- CorrectAssassinKills, IncorrectAssassinKills
EndGameSummary- String summary for UI
-
Role System
ITownOfUsRoleinterface withRoleAlignmentenumModdedRoleTeamsenum (Crewmate, Impostor, Neutral, Custom)- Role name resolution via
GetRoleName() - Support for role history tracking
-
Modifier System
GameModifier,TouGameModifier,UniversalGameModifierAllianceGameModifierfor team alliances- Modifier color and name support
Current Data Flow
Game End →
AmongUsClient.OnGameEnd [Harmony Patch] →
BuildEndGameData() →
Iterate PlayerControl.AllPlayerControls →
Extract role history from GameHistory.RoleHistory →
Extract modifiers from playerControl.GetModifiers() →
Extract stats from GameHistory.PlayerStats →
Build RoleString with colors/formatting →
Check winner status from EndGameResult.CachedWinners →
Add to EndGameData.PlayerRecords →
EndGameManager.Start [Harmony Patch] →
BuildEndGameSummary() →
Display UI with player records
Differences from ToU-stats Reference
| Aspect | ToU-stats | ToU Mira |
|---|---|---|
| Framework | Harmony patches only | MiraAPI + Harmony |
| Role Storage | Role.RoleHistory |
GameHistory.RoleHistory (MiraAPI RoleBehaviour) |
| Role Types | Custom enum RoleEnum |
MiraAPI RoleTypes + custom roles |
| Stats Storage | Within role string parsing | GameHistory.PlayerStats dictionary |
| Team System | Simple faction checks | ModdedRoleTeams enum + RoleAlignment |
| Modifiers | String parsing from role string | MiraAPI modifier system |
| Platform Detection | Hardcoded "PC" | Can detect from player data |
| Task Tracking | Role-based TotalTasks/TasksLeft |
Available via player stats |
Reference Implementation Analysis
ToU-stats EndGamePatch.cs Architecture
Key Classes:
-
ApiConfig
EnableApiExport(bool) - Master toggleApiToken(string) - Authentication tokenApiEndpoint(string) - Target URLSaveLocalBackup(bool) - Local JSON savingSecret(string) - Additional security key
-
Data Models
GameApiData- Root containerGameInfo- Match metadataPlayerData- Individual player statisticsPlayerStats- Numerical statsGameResult- Win conditionsSpecialWinner- Secondary winners (Jester, Executioner, etc.)
-
Processing Pipeline
SendGameDataToApi() [async] → ReadApiConfig() → BuildGameData() → ExtractPlayerData() for each player → CleanPlayerName() → IsPlayerWinner() → ExtractMainRole() → ExtractModifiers() [string parsing] → ExtractStats() [regex parsing] → DetermineWinningTeam() → SaveJsonLocally() [if enabled] → SendToApi() [HTTP POST] -
Key Features
- Async execution to avoid blocking UI
- Local data copy before async processing
- INI file configuration (game dir or Documents/TownOfUs)
- 30-second HTTP timeout
- Comprehensive error logging
- 1-second delay before clearing data
Architecture Design
System Components
┌─────────────────────────────────────────────────────────────┐
│ TownOfUs Plugin │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ EndGamePatches.cs (existing) │ │
│ │ - BuildEndGameData() │ │
│ │ - AmongUsClientGameEndPatch() [Harmony] │ │
│ └───────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ GameStatsExporter.cs (NEW) │ │
│ │ - ExportGameDataAsync() │ │
│ │ - BuildExportData() │ │
│ │ - SendToApiAsync() │ │
│ │ - SaveLocalBackupAsync() │ │
│ └───────────────┬───────────────────────────────────────┘ │
│ │ │
│ ├─────────────────┬────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────┐ │
│ │ ApiConfigManager│ │ GameDataBuilder │ │ DataModels│ │
│ │ (NEW) │ │ (NEW) │ │ (NEW) │ │
│ └──────────────────┘ └──────────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
Module Descriptions
1. GameStatsExporter (Main Orchestrator)
Location: TownOfUs/Modules/GameStatsExporter.cs
Responsibilities:
- Coordinate the export process
- Manage async execution
- Handle errors and logging
- Interface with existing EndGameData
Key Methods:
public static async Task ExportGameDataAsync()
public static Task ExportGameDataBackground() // Fire-and-forget wrapper
2. ApiConfigManager (Configuration Handler)
Location: TownOfUs/Modules/Stats/ApiConfigManager.cs
Responsibilities:
- Read/write ApiSet.ini configuration
- Validate configuration values
- Provide default configuration template
- Search in multiple locations (game dir, Documents)
Configuration Locations:
{GameDirectory}/ApiSet.ini{Documents}/TownOfUs/ApiSet.ini
3. GameDataBuilder (Data Transformation)
Location: TownOfUs/Modules/Stats/GameDataBuilder.cs
Responsibilities:
- Transform EndGameData to export format
- Extract role names (clean, without color tags)
- Build role history arrays
- Map modifiers to strings
- Determine winning team
- Calculate task completion
Key Methods:
public static GameStatsData BuildExportData(ApiConfig config)
private static PlayerStatsData BuildPlayerData(EndGameData.PlayerRecord record)
private static string ExtractRoleName(RoleBehaviour role)
private static List<string> ExtractRoleHistory(byte playerId)
private static List<string> ExtractModifiers(PlayerControl player)
private static string DetermineWinningTeam()
4. DataModels (Data Structures)
Location: TownOfUs/Modules/Stats/GameStatsModels.cs
Classes:
GameStatsData- Root export objectGameInfoData- Match metadataPlayerStatsData- Individual player dataPlayerStatsNumbers- Numerical statisticsGameResultData- Win conditions
Integration Points
Integration with EndGamePatches.cs
Modified Patch:
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
[HarmonyPostfix]
public static void EndGameManagerStart(EndGameManager __instance)
{
BuildEndGameSummary(__instance);
// NEW: Trigger stats export
if (GameOptionsManager.Instance.CurrentGameOptions.GameMode != GameModes.HideNSeek)
{
_ = GameStatsExporter.ExportGameDataBackground();
}
}
Integration with GameHistory
- Read from
GameHistory.RoleHistoryfor role sequences - Read from
GameHistory.PlayerStatsfor statistics - Read from
GameHistory.KilledPlayersfor kill tracking - Use
GameHistory.WinningFactionif available
Implementation Roadmap
Phase 1: Data Models (Day 1)
Tasks:
- Create
TownOfUs/Modules/Stats/directory - Implement
GameStatsModels.cswith all data classes - Add JSON serialization attributes
- Write unit tests for model serialization
Deliverables:
- ✓ All data model classes
- ✓ JSON serialization working
- ✓ Test coverage for models
Phase 2: Configuration System (Day 1-2)
Tasks:
- Implement
ApiConfigManager.cs - Create INI file reading/writing logic
- Implement configuration validation
- Create default configuration template
- Test multi-location search
Deliverables:
- ✓ Config file reading/writing
- ✓ Default template generation
- ✓ Validation logic
- ✓ Error handling
Phase 3: Data Builder (Day 2-3)
Tasks:
- Implement
GameDataBuilder.cs - Create role name extraction (strip color codes)
- Build role history from GameHistory
- Extract modifiers from MiraAPI modifier system
- Map ModdedRoleTeams to winning team strings
- Calculate task completion percentages
- Handle platform detection
- Test with various game scenarios
Deliverables:
- ✓ Complete data transformation
- ✓ Role history extraction
- ✓ Modifier mapping
- ✓ Stats aggregation
- ✓ Test coverage
Phase 4: Export System (Day 3-4)
Tasks:
- Implement
GameStatsExporter.cs - Create async export pipeline
- Implement HTTP client with timeout
- Add local backup functionality
- Implement comprehensive logging
- Add error handling and recovery
Deliverables:
- ✓ Async export working
- ✓ HTTP POST implementation
- ✓ Local backup system
- ✓ Error handling
- ✓ Logging integration
Phase 5: Integration (Day 4-5)
Tasks:
- Modify
EndGamePatches.csto call exporter - Test with actual game sessions
- Verify data accuracy
- Test error scenarios (no config, network failure)
- Verify UI doesn't block during export
Deliverables:
- ✓ Integrated with EndGame flow
- ✓ No blocking on UI
- ✓ Error scenarios handled
- ✓ Data validation passed
Phase 6: Testing and Documentation (Day 5-6)
Tasks:
- Comprehensive integration testing
- Performance testing (large lobbies)
- Network failure scenarios
- Configuration validation testing
- Update user documentation
- Create API endpoint specification
Deliverables:
- ✓ Full test suite
- ✓ Performance benchmarks
- ✓ User documentation
- ✓ API specification
- ✓ Configuration guide
Technical Specifications
Configuration File Format
File Name: ApiSet.ini
Locations (Priority Order):
{GameInstallDir}/ApiSet.ini{UserDocuments}/TownOfUs/ApiSet.ini
Format:
# TownOfUs API Exporter Configuration
# Whether to enable API export (true/false)
EnableApiExport=false
# API Authentication Token
ApiToken=
# API Endpoint URL
ApiEndpoint=
# Whether to save local backup copies (true/false)
SaveLocalBackup=false
# Additional secret/password for API authentication
Secret=
# Example configuration:
# EnableApiExport=true
# ApiToken=your_secret_token_here
# ApiEndpoint=https://api.example.com/api/among-data
# SaveLocalBackup=true
# Secret=your_secret_key_here
Data Extraction Rules
Role Name Extraction
// ToU Mira uses RoleBehaviour from MiraAPI
private static string ExtractRoleName(RoleBehaviour role)
{
if (role == null) return "Unknown";
// Use GetRoleName() for localized name
var name = role.GetRoleName();
// Strip color tags
name = Regex.Replace(name, @"<color=#[A-Fa-f0-9]+>", "");
name = name.Replace("</color>", "");
return name.Trim();
}
Role History Extraction
private static List<string> ExtractRoleHistory(byte playerId)
{
var roles = new List<string>();
foreach (var roleEntry in GameHistory.RoleHistory.Where(x => x.Key == playerId))
{
var role = roleEntry.Value;
// Skip ghost roles
if (role.Role is RoleTypes.CrewmateGhost or RoleTypes.ImpostorGhost ||
role.Role == (RoleTypes)RoleId.Get<NeutralGhostRole>())
{
continue;
}
roles.Add(ExtractRoleName(role));
}
return roles;
}
Modifier Extraction
private static List<string> ExtractModifiers(PlayerControl player)
{
var modifiers = new List<string>();
// Get all game modifiers (TOU and Universal)
var playerModifiers = player.GetModifiers<GameModifier>()
.Where(x => x is TouGameModifier || x is UniversalGameModifier);
foreach (var modifier in playerModifiers)
{
modifiers.Add(modifier.ModifierName);
}
return modifiers;
}
Winning Team Determination
private static string DetermineWinningTeam()
{
// Use GameHistory.WinningFaction if available
if (!string.IsNullOrEmpty(GameHistory.WinningFaction))
{
return GameHistory.WinningFaction;
}
// Fallback: Check winner records
var winners = EndGameData.PlayerRecords.Where(x => x.Winner).ToList();
if (!winners.Any()) return "Unknown";
var firstWinner = winners.First();
return firstWinner.Team switch
{
ModdedRoleTeams.Crewmate => "Crewmates",
ModdedRoleTeams.Impostor => "Impostors",
ModdedRoleTeams.Neutral => "Neutrals",
_ => "Unknown"
};
}
Platform Detection
private static string GetPlayerPlatform(PlayerControl player)
{
// Check player platform data
if (player.Data?.Platform != null)
{
return player.Data.Platform.ToString();
}
// Default fallback
return "Unknown";
}
Player Tag Extraction
private static string GetPlayerTag(PlayerControl player)
{
// Check if player has friend code/tag visible
if (player.Data?.FriendCode != null && !string.IsNullOrEmpty(player.Data.FriendCode))
{
return player.Data.FriendCode;
}
return null;
}
HTTP Communication
Request Format
POST {ApiEndpoint}
Content-Type: application/json
User-Agent: TownOfUs-Mira-DataExporter/1.2.1
{GameStatsData JSON}
Timeout Configuration
- Connection timeout: 30 seconds
- Read/Write timeout: 30 seconds
Error Handling
- Network errors: Log and continue (don't crash game)
- Timeout errors: Log timeout message
- HTTP error codes: Log status code and response body
- JSON serialization errors: Log error and data that failed
Local Backup System
Directory: {UserDocuments}/TownOfUs/GameLogs/
File Naming: Game_{yyyyMMdd_HHmmss}_{gameId}.json
Example: Game_20250921_210247_b2fe65e1.json
Format: Pretty-printed JSON (WriteIndented=true)
Testing Strategy
Unit Tests
Location: TownOfUs.Tests/Modules/Stats/
Test Cases:
-
Model Serialization
- JSON serialization/deserialization
- Null value handling
- Empty collection handling
-
Configuration Manager
- INI file parsing
- Default value handling
- Invalid configuration handling
- Multi-location search
-
Data Builder
- Role name extraction
- Color tag stripping
- Modifier extraction
- Stats aggregation
- Team determination
Integration Tests
Test Scenarios:
-
Complete Game Flow
- Start game with 10 players
- Assign various roles and modifiers
- Play to completion
- Verify exported data accuracy
-
Network Scenarios
- Successful API call
- Network timeout
- Connection failure
- Invalid endpoint
- HTTP error responses (4xx, 5xx)
-
Configuration Scenarios
- No config file (disabled)
- Config in game directory
- Config in documents directory
- Invalid config values
- Partial configuration
-
Edge Cases
- Hide & Seek mode (should skip export)
- Empty lobby
- All spectators
- Role changes (Amnesiac)
- Multiple modifiers per player
Performance Tests
Metrics:
- Export time for 15 players: < 500ms
- UI blocking time: 0ms (async execution)
- Memory usage increase: < 5MB
- JSON size for 15 players: ~10-15KB
Security Considerations
Configuration Security
-
Token Protection
- Store tokens in INI file (not in code)
- INI file should not be committed to repository
- Add ApiSet.ini to .gitignore
-
Secret Key
- Additional authentication layer
- Prevents accidental data submission
- Server-side validation required
-
File Permissions
- INI file readable only by game process
- Local backup directory permissions restricted
Network Security
-
HTTPS Enforcement
- Require HTTPS endpoints
- Validate SSL certificates
- Reject self-signed certificates in production
-
Data Validation
- Validate endpoint URL format
- Sanitize player names (XSS prevention)
- Limit JSON payload size
-
Privacy
- No personally identifiable information (PII)
- Player names only (not IP addresses)
- Optional: Hash player names before sending
Error Message Security
- Don't expose full file paths in logs
- Don't log sensitive tokens
- Redact tokens in error messages
Maintenance and Monitoring
Logging Strategy
Log Levels:
- Info: Configuration loaded, export started/completed
- Warning: Config not found, export disabled, network timeout
- Error: HTTP errors, serialization failures, file I/O errors
Log Messages:
// Startup
Logger<TownOfUsPlugin>.Info("GameStatsExporter initialized");
// Configuration
Logger<TownOfUsPlugin>.Info($"Config loaded: EnableExport={config.EnableApiExport}, Endpoint configured={!string.IsNullOrEmpty(config.ApiEndpoint)}");
// Export
Logger<TownOfUsPlugin>.Info("Starting game data export...");
Logger<TownOfUsPlugin>.Info($"Game data exported successfully. Players: {data.Players.Count}, Duration: {sw.ElapsedMilliseconds}ms");
// Errors
Logger<TownOfUsPlugin>.Error($"Failed to send to API: {ex.Message}");
Logger<TownOfUsPlugin>.Warning("API export is disabled - skipping");
Monitoring Metrics
Client-Side:
- Export success rate
- Average export duration
- Network error rate
- Local backup success rate
Server-Side (API):
- Requests per minute
- Invalid token rate
- Data validation failure rate
- Average payload size
Version Compatibility
API Versioning:
Add version field to GameStatsData:
{
"version": "1.0",
"modVersion": "1.2.1",
...
}
Breaking Changes:
- Increment major version (1.0 → 2.0)
- Maintain backward compatibility for 1 version
Non-Breaking Changes:
- Add optional fields with defaults
- Add new enum values with "Unknown" fallback
Troubleshooting Guide
Common Issues:
-
Export not working
- Check
EnableApiExport=truein config - Verify endpoint URL is correct
- Check BepInEx logs for errors
- Check
-
Network timeout
- Verify internet connection
- Check firewall settings
- Verify endpoint is accessible
-
Invalid data
- Check JSON format in local backup
- Verify all roles have names
- Check for null reference errors
-
Performance issues
- Check export duration in logs
- Verify async execution (UI not blocking)
- Consider disabling local backup
Appendix A: File Structure
TownOfUs/
├── Modules/
│ ├── GameHistory.cs (existing)
│ ├── Stats/
│ │ ├── GameStatsExporter.cs (NEW)
│ │ ├── ApiConfigManager.cs (NEW)
│ │ ├── GameDataBuilder.cs (NEW)
│ │ └── GameStatsModels.cs (NEW)
│ └── ...
├── Patches/
│ ├── EndGamePatches.cs (modified)
│ └── ...
└── ...
Documents/TownOfUs/
├── ApiSet.ini (user config)
└── GameLogs/
├── Game_20250921_210247_b2fe65e1.json
└── ...
Appendix B: API Endpoint Specification
Request
Method: POST
Content-Type: application/json
Headers:
User-Agent: TownOfUs-Mira-DataExporter/{version}
Body: GameStatsData JSON
Response
Success (200 OK):
{
"success": true,
"message": "Game data received",
"gameId": "b2fe65e1-46f4-4a84-b60b-3c84f5fcc320"
}
Error (400 Bad Request):
{
"success": false,
"error": "Invalid token",
"code": "AUTH_ERROR"
}
Error (422 Unprocessable Entity):
{
"success": false,
"error": "Invalid data format",
"code": "VALIDATION_ERROR",
"details": ["Missing required field: gameInfo.timestamp"]
}
Appendix C: Migration from ToU-stats
Key Differences Table
| Aspect | ToU-stats | ToU Mira Implementation |
|---|---|---|
| Role Access | Role.RoleHistory |
GameHistory.RoleHistory |
| Role Type | RoleEnum |
RoleBehaviour + RoleTypes |
| Stats Access | Parsed from role string | GameHistory.PlayerStats dictionary |
| Modifier Access | String parsing | player.GetModifiers<GameModifier>() |
| Team System | String-based faction checks | ModdedRoleTeams enum |
| Color Removal | Not needed (stored separately) | Strip from GetRoleName() |
| Task Info | player.TotalTasks, player.TasksLeft |
Available via GameHistory |
| Platform | Hardcoded "PC" | player.Data.Platform |
| Async Pattern | Task.Run(async () => {...}) |
Same pattern maintained |
Code Mapping Examples
Role History Loop:
// ToU-stats
foreach (var role in Role.RoleHistory.Where(x => x.Key == playerControl.PlayerId))
{
if (role.Value == RoleEnum.Crewmate) { ... }
}
// ToU Mira
foreach (var roleEntry in GameHistory.RoleHistory.Where(x => x.Key == playerControl.PlayerId))
{
var role = roleEntry.Value;
if (role.Role == RoleTypes.Crewmate) { ... }
}
Stats Access:
// ToU-stats
var player = Role.GetRole(playerControl);
stats.Kills = player.Kills;
// ToU Mira
if (GameHistory.PlayerStats.TryGetValue(playerControl.PlayerId, out var stats))
{
// Use stats directly
}
Appendix D: Example JSON Output
See reference file: ToU-stats/Game_20250921_210247_b2fe65e1.json
Document Change Log
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.0 | 2025-10-07 | Initial document creation | Analysis |
End of Implementation Plan