Files
MiraExporter/DOC/GameStats_Pure_Standalone_Implementation.md
2025-10-08 01:39:13 +02:00

60 KiB

Game Statistics Export - Pure Standalone Implementation

Complete DLL Plugin Without TOU Mira Modifications

Document Version: 1.0 Date: 2025-10-07 Scenario: Pure Standalone - Zero TOU Mira code changes


Table of Contents

  1. Overview
  2. Architecture
  3. Complete Implementation
  4. Reflection Layer
  5. Version Compatibility
  6. Testing Strategy
  7. Deployment Guide
  8. Troubleshooting
  9. Limitations

Overview

Concept

Ten dokument opisuje implementację w 100% standalone plugin DLL, który:

  • NIE modyfikuje żadnego kodu TOU Mira
  • Używa refleksji do dostępu do publicznych klas
  • Używa Harmony patches do podpięcia się pod event flow
  • Jest całkowicie opcjonalny dla użytkowników
  • Może być instalowany/usuwany bez przebudowy TOU Mira

Directory Structure

Among Us/
└── BepInEx/
    └── plugins/
        ├── TownOfUsMira.dll          # Oryginalny mod (NIEZMIENIONY)
        ├── MiraAPI.dll                # Dependency
        ├── Reactor.dll                # Dependency
        └── TownOfUsStatsExporter.dll  # ← NOWY plugin (standalone)

User Installation

1. Download TownOfUsStatsExporter.dll
2. Copy to BepInEx/plugins/
3. Create ApiSet.ini with configuration
4. Done! Stats will be exported automatically

Architecture

High-Level Design

┌─────────────────────────────────────────────────────────────────┐
│                    Among Us Game Process                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │         TownOfUsMira.dll (UNTOUCHED)                     │   │
│  │                                                          │   │
│  │  ┌────────────────────────────────────────────────┐     │   │
│  │  │  EndGamePatches.BuildEndGameData()             │     │   │
│  │  │  - Populates EndGameData.PlayerRecords         │     │   │
│  │  └────────────────────────────────────────────────┘     │   │
│  │                                                          │   │
│  │  ┌────────────────────────────────────────────────┐     │   │
│  │  │  GameHistory (public static class)             │     │   │
│  │  │  - RoleHistory                                 │     │   │
│  │  │  - PlayerStats                                 │     │   │
│  │  │  - KilledPlayers                               │     │   │
│  │  └────────────────────────────────────────────────┘     │   │
│  │                                                          │   │
│  └──────────────────────────────────────────────────────────┘   │
│                           ▲                                      │
│                           │ Reflection Access                    │
│                           │                                      │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │     TownOfUsStatsExporter.dll (STANDALONE PLUGIN)        │   │
│  │                                                          │   │
│  │  ┌────────────────────────────────────────────────┐     │   │
│  │  │  Harmony Patch on EndGameManager.Start         │     │   │
│  │  │  - Lower priority (runs AFTER TOU Mira)       │     │   │
│  │  └────────────────────────────────────────────────┘     │   │
│  │                           │                              │   │
│  │                           ▼                              │   │
│  │  ┌────────────────────────────────────────────────┐     │   │
│  │  │  TouMiraReflectionBridge                       │     │   │
│  │  │  - GetEndGameData()                            │     │   │
│  │  │  - GetGameHistory()                            │     │   │
│  │  │  - GetPlayerStats()                            │     │   │
│  │  └────────────────────────────────────────────────┘     │   │
│  │                           │                              │   │
│  │                           ▼                              │   │
│  │  ┌────────────────────────────────────────────────┐     │   │
│  │  │  StatsExporter                                 │     │   │
│  │  │  - Transform data                              │     │   │
│  │  │  - Send to API                                 │     │   │
│  │  │  - Save local backup                           │     │   │
│  │  └────────────────────────────────────────────────┘     │   │
│  │                                                          │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Component Diagram

TownOfUsStatsExporter.dll
├── TownOfUsStatsPlugin.cs          # BepInEx plugin entry point
├── Patches/
│   └── EndGameExportPatch.cs       # Harmony patch (low priority)
├── Reflection/
│   ├── TouMiraReflectionBridge.cs  # Main reflection interface
│   ├── ReflectionCache.cs          # Cached reflection metadata
│   ├── VersionCompatibility.cs     # Version checking
│   └── IL2CPPHelper.cs             # IL2CPP type conversions
├── Export/
│   ├── StatsExporter.cs            # Main export orchestrator
│   ├── DataTransformer.cs          # Transform TOU data to export format
│   └── ApiClient.cs                # HTTP client for API
├── Config/
│   ├── ApiConfigManager.cs         # INI file reader
│   └── ApiConfig.cs                # Config model
└── Models/
    ├── GameStatsData.cs            # Export data models
    └── ReflectedData.cs            # DTOs for reflected data

Complete Implementation

1. Plugin Entry Point

File: TownOfUsStatsPlugin.cs

using BepInEx;
using BepInEx.Unity.IL2CPP;
using BepInEx.Logging;
using HarmonyLib;
using System;
using System.Reflection;

namespace TownOfUsStatsExporter;

[BepInPlugin(PluginGuid, PluginName, PluginVersion)]
[BepInDependency("auavengers.tou.mira", BepInDependency.DependencyFlags.HardDependency)]
[BepInDependency("gg.reactor.api", BepInDependency.DependencyFlags.HardDependency)]
[BepInDependency("me.mira.api", BepInDependency.DependencyFlags.HardDependency)]
public class TownOfUsStatsPlugin : BasePlugin
{
    public const string PluginGuid = "com.townofus.stats.exporter";
    public const string PluginName = "TownOfUs Stats Exporter";
    public const string PluginVersion = "1.0.0";

    internal static ManualLogSource Logger { get; private set; } = null!;
    internal static Harmony Harmony { get; private set; } = null!;

    private TouMiraReflectionBridge? reflectionBridge;

    public override void Load()
    {
        Logger = Log;
        Harmony = new Harmony(PluginGuid);

        Logger.LogInfo("========================================");
        Logger.LogInfo($"{PluginName} v{PluginVersion}");
        Logger.LogInfo("========================================");

        // Initialize reflection bridge
        reflectionBridge = new TouMiraReflectionBridge();

        if (!reflectionBridge.Initialize())
        {
            Logger.LogError("Failed to initialize TOU Mira reflection bridge!");
            Logger.LogError("This plugin may not be compatible with your TOU Mira version.");
            Logger.LogError("Plugin will be disabled.");
            return;
        }

        Logger.LogInfo($"Successfully connected to TOU Mira v{reflectionBridge.TouMiraVersion}");
        Logger.LogInfo($"Compatibility: {reflectionBridge.CompatibilityStatus}");

        // Store bridge in static context for patches
        ReflectionBridgeProvider.SetBridge(reflectionBridge);

        // Apply Harmony patches
        try
        {
            Harmony.PatchAll(Assembly.GetExecutingAssembly());
            Logger.LogInfo("Harmony patches applied successfully");
        }
        catch (Exception ex)
        {
            Logger.LogError($"Failed to apply Harmony patches: {ex}");
            return;
        }

        Logger.LogInfo($"{PluginName} loaded successfully!");
        Logger.LogInfo("Stats will be exported at the end of each game.");
    }

    public override bool Unload()
    {
        Logger.LogInfo($"Unloading {PluginName}...");
        Harmony?.UnpatchSelf();
        return true;
    }
}

/// <summary>
/// Static provider for accessing reflection bridge from patches
/// </summary>
internal static class ReflectionBridgeProvider
{
    private static TouMiraReflectionBridge? bridge;

    public static void SetBridge(TouMiraReflectionBridge b) => bridge = b;
    public static TouMiraReflectionBridge GetBridge() => bridge ?? throw new InvalidOperationException("Bridge not initialized");
}

2. Harmony Patch

File: Patches/EndGameExportPatch.cs

using HarmonyLib;
using System;
using System.Threading.Tasks;

namespace TownOfUsStatsExporter.Patches;

/// <summary>
/// Patch on EndGameManager.Start to trigger stats export.
/// Uses Low priority to execute AFTER TOU Mira's patch.
/// </summary>
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
public static class EndGameExportPatch
{
    /// <summary>
    /// Postfix patch - runs after original method and TOU Mira's patch
    /// </summary>
    [HarmonyPostfix]
    [HarmonyPriority(Priority.Low)]  // Run AFTER TOU Mira (which uses normal priority)
    public static void Postfix(EndGameManager __instance)
    {
        try
        {
            TownOfUsStatsPlugin.Logger.LogInfo("=== End Game Export Patch Triggered ===");

            // Check if this is Hide & Seek mode (skip export)
            if (GameOptionsManager.Instance?.CurrentGameOptions?.GameMode == GameModes.HideNSeek)
            {
                TownOfUsStatsPlugin.Logger.LogInfo("Hide & Seek mode detected - skipping export");
                return;
            }

            // Fire-and-forget async export (don't block UI)
            _ = Task.Run(async () =>
            {
                try
                {
                    await StatsExporter.ExportGameStatsAsync();
                }
                catch (Exception ex)
                {
                    TownOfUsStatsPlugin.Logger.LogError($"Unhandled error in stats export: {ex}");
                }
            });

            TownOfUsStatsPlugin.Logger.LogInfo("Stats export task started in background");
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error in EndGameExportPatch: {ex}");
        }
    }
}

3. Reflection Bridge (Core)

File: Reflection/TouMiraReflectionBridge.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;

namespace TownOfUsStatsExporter.Reflection;

/// <summary>
/// Main bridge for accessing TOU Mira data through reflection.
/// Caches all reflection metadata for performance.
/// </summary>
public class TouMiraReflectionBridge
{
    private Assembly? touAssembly;
    private ReflectionCache cache = new();

    public string? TouMiraVersion { get; private set; }
    public string CompatibilityStatus { get; private set; } = "Unknown";

    /// <summary>
    /// Initialize the reflection bridge by finding TOU Mira and caching reflection metadata
    /// </summary>
    public bool Initialize()
    {
        try
        {
            TownOfUsStatsPlugin.Logger.LogInfo("Initializing TOU Mira reflection bridge...");

            // Find TOU Mira assembly
            touAssembly = AppDomain.CurrentDomain.GetAssemblies()
                .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

            if (touAssembly == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("TOU Mira assembly not found!");
                return false;
            }

            TouMiraVersion = touAssembly.GetName().Version?.ToString() ?? "Unknown";
            TownOfUsStatsPlugin.Logger.LogInfo($"Found TOU Mira assembly v{TouMiraVersion}");

            // Check version compatibility
            CompatibilityStatus = VersionCompatibility.CheckVersion(TouMiraVersion);
            if (CompatibilityStatus.StartsWith("Unsupported"))
            {
                TownOfUsStatsPlugin.Logger.LogWarning($"Version compatibility: {CompatibilityStatus}");
                TownOfUsStatsPlugin.Logger.LogWarning("Plugin may not work correctly!");
            }

            // Cache reflection metadata
            if (!CacheReflectionMetadata())
            {
                TownOfUsStatsPlugin.Logger.LogError("Failed to cache reflection metadata");
                return false;
            }

            TownOfUsStatsPlugin.Logger.LogInfo("Reflection bridge initialized successfully");
            return true;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Failed to initialize reflection bridge: {ex}");
            return false;
        }
    }

    private bool CacheReflectionMetadata()
    {
        try
        {
            // Find and cache EndGamePatches type
            cache.EndGamePatchesType = touAssembly!.GetType("TownOfUs.Patches.EndGamePatches");
            if (cache.EndGamePatchesType == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Patches.EndGamePatches");
                return false;
            }

            // Find and cache EndGameData nested type
            cache.EndGameDataType = cache.EndGamePatchesType.GetNestedType("EndGameData", BindingFlags.Public);
            if (cache.EndGameDataType == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Type not found: EndGameData");
                return false;
            }

            // Find and cache PlayerRecord nested type
            cache.PlayerRecordType = cache.EndGameDataType.GetNestedType("PlayerRecord", BindingFlags.Public);
            if (cache.PlayerRecordType == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Type not found: PlayerRecord");
                return false;
            }

            // Cache PlayerRecords property
            cache.PlayerRecordsProperty = cache.EndGameDataType.GetProperty("PlayerRecords",
                BindingFlags.Public | BindingFlags.Static);
            if (cache.PlayerRecordsProperty == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Property not found: EndGameData.PlayerRecords");
                return false;
            }

            // Find and cache GameHistory type
            cache.GameHistoryType = touAssembly.GetType("TownOfUs.Modules.GameHistory");
            if (cache.GameHistoryType == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Type not found: TownOfUs.Modules.GameHistory");
                return false;
            }

            // Cache GameHistory properties
            cache.PlayerStatsProperty = cache.GameHistoryType.GetProperty("PlayerStats",
                BindingFlags.Public | BindingFlags.Static);
            cache.RoleHistoryProperty = cache.GameHistoryType.GetProperty("RoleHistory",
                BindingFlags.Public | BindingFlags.Static);
            cache.KilledPlayersProperty = cache.GameHistoryType.GetProperty("KilledPlayers",
                BindingFlags.Public | BindingFlags.Static);
            cache.WinningFactionProperty = cache.GameHistoryType.GetProperty("WinningFaction",
                BindingFlags.Public | BindingFlags.Static);

            if (cache.PlayerStatsProperty == null || cache.RoleHistoryProperty == null)
            {
                TownOfUsStatsPlugin.Logger.LogError("Required GameHistory properties not found");
                return false;
            }

            TownOfUsStatsPlugin.Logger.LogInfo("All required types and properties cached successfully");
            return true;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error caching reflection metadata: {ex}");
            return false;
        }
    }

    /// <summary>
    /// Get player records from EndGameData
    /// </summary>
    public List<PlayerRecordData> GetPlayerRecords()
    {
        try
        {
            var playerRecords = cache.PlayerRecordsProperty!.GetValue(null);
            if (playerRecords == null)
            {
                TownOfUsStatsPlugin.Logger.LogWarning("PlayerRecords is null");
                return new List<PlayerRecordData>();
            }

            // Handle IL2CPP list
            var recordsList = IL2CPPHelper.ConvertToManagedList(playerRecords);
            var result = new List<PlayerRecordData>();

            foreach (var record in recordsList)
            {
                if (record == null) continue;

                result.Add(new PlayerRecordData
                {
                    PlayerName = GetPropertyValue<string>(record, "PlayerName") ?? "Unknown",
                    RoleString = GetPropertyValue<string>(record, "RoleString") ?? "",
                    Winner = GetPropertyValue<bool>(record, "Winner"),
                    PlayerId = GetPropertyValue<byte>(record, "PlayerId"),
                    TeamString = GetTeamString(record)
                });
            }

            TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved {result.Count} player records");
            return result;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting player records: {ex}");
            return new List<PlayerRecordData>();
        }
    }

    /// <summary>
    /// Get player statistics from GameHistory
    /// </summary>
    public Dictionary<byte, PlayerStatsData> GetPlayerStats()
    {
        try
        {
            var playerStats = cache.PlayerStatsProperty!.GetValue(null);
            if (playerStats == null)
            {
                TownOfUsStatsPlugin.Logger.LogWarning("PlayerStats is null");
                return new Dictionary<byte, PlayerStatsData>();
            }

            var statsDict = (IDictionary)playerStats;
            var result = new Dictionary<byte, PlayerStatsData>();

            foreach (DictionaryEntry entry in statsDict)
            {
                var playerId = (byte)entry.Key;
                var stats = entry.Value;

                if (stats == null) continue;

                result[playerId] = new PlayerStatsData
                {
                    CorrectKills = GetPropertyValue<int>(stats, "CorrectKills"),
                    IncorrectKills = GetPropertyValue<int>(stats, "IncorrectKills"),
                    CorrectAssassinKills = GetPropertyValue<int>(stats, "CorrectAssassinKills"),
                    IncorrectAssassinKills = GetPropertyValue<int>(stats, "IncorrectAssassinKills")
                };
            }

            TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved stats for {result.Count} players");
            return result;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting player stats: {ex}");
            return new Dictionary<byte, PlayerStatsData>();
        }
    }

    /// <summary>
    /// Get role history from GameHistory
    /// </summary>
    public Dictionary<byte, List<string>> GetRoleHistory()
    {
        try
        {
            var roleHistory = cache.RoleHistoryProperty!.GetValue(null);
            if (roleHistory == null)
            {
                TownOfUsStatsPlugin.Logger.LogWarning("RoleHistory is null");
                return new Dictionary<byte, List<string>>();
            }

            var historyList = IL2CPPHelper.ConvertToManagedList(roleHistory);
            var result = new Dictionary<byte, List<string>>();

            foreach (var entry in historyList)
            {
                if (entry == null) continue;

                // Entry is KeyValuePair<byte, RoleBehaviour>
                var kvpType = entry.GetType();
                var playerId = (byte)kvpType.GetProperty("Key")!.GetValue(entry)!;
                var roleBehaviour = kvpType.GetProperty("Value")!.GetValue(entry);

                if (roleBehaviour == null) continue;

                // Get role name from RoleBehaviour.GetRoleName()
                var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName");
                if (getRoleNameMethod == null) continue;

                var roleName = (string)getRoleNameMethod.Invoke(roleBehaviour, null)!;

                // Skip ghost roles
                if (roleName.Contains("Ghost")) continue;

                // Strip color tags
                roleName = StripColorTags(roleName);

                if (!result.ContainsKey(playerId))
                {
                    result[playerId] = new List<string>();
                }

                result[playerId].Add(roleName);
            }

            TownOfUsStatsPlugin.Logger.LogInfo($"Retrieved role history for {result.Count} players");
            return result;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting role history: {ex}");
            return new Dictionary<byte, List<string>>();
        }
    }

    /// <summary>
    /// Get killed players list
    /// </summary>
    public List<KilledPlayerData> GetKilledPlayers()
    {
        try
        {
            var killedPlayers = cache.KilledPlayersProperty?.GetValue(null);
            if (killedPlayers == null)
            {
                return new List<KilledPlayerData>();
            }

            var killedList = IL2CPPHelper.ConvertToManagedList(killedPlayers);
            var result = new List<KilledPlayerData>();

            foreach (var killed in killedList)
            {
                if (killed == null) continue;

                result.Add(new KilledPlayerData
                {
                    KillerId = GetPropertyValue<byte>(killed, "KillerId"),
                    VictimId = GetPropertyValue<byte>(killed, "VictimId")
                });
            }

            return result;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting killed players: {ex}");
            return new List<KilledPlayerData>();
        }
    }

    /// <summary>
    /// Get winning faction string
    /// </summary>
    public string GetWinningFaction()
    {
        try
        {
            if (cache.WinningFactionProperty == null)
                return string.Empty;

            var winningFaction = cache.WinningFactionProperty.GetValue(null);
            return winningFaction as string ?? string.Empty;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting winning faction: {ex}");
            return string.Empty;
        }
    }

    /// <summary>
    /// Get modifiers for a player
    /// </summary>
    public List<string> GetPlayerModifiers(byte playerId)
    {
        try
        {
            // Find PlayerControl
            var player = PlayerControl.AllPlayerControls.ToArray()
                .FirstOrDefault(p => p.PlayerId == playerId);

            if (player == null)
                return new List<string>();

            // Get modifiers through reflection
            // player.GetModifiers<GameModifier>() but through reflection
            var getModifiersMethod = player.GetType().GetMethods()
                .FirstOrDefault(m => m.Name == "GetModifiers" && m.IsGenericMethod);

            if (getModifiersMethod == null)
                return new List<string>();

            // Find GameModifier type
            var gameModifierType = touAssembly!.GetType("MiraAPI.Modifiers.GameModifier");
            if (gameModifierType == null)
                return new List<string>();

            var genericMethod = getModifiersMethod.MakeGenericMethod(gameModifierType);
            var modifiers = genericMethod.Invoke(player, null);

            if (modifiers == null)
                return new List<string>();

            var modifiersList = IL2CPPHelper.ConvertToManagedList(modifiers);
            var result = new List<string>();

            foreach (var modifier in modifiersList)
            {
                if (modifier == null) continue;

                var modifierName = GetPropertyValue<string>(modifier, "ModifierName");
                if (!string.IsNullOrEmpty(modifierName))
                {
                    result.Add(modifierName);
                }
            }

            return result;
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error getting modifiers for player {playerId}: {ex}");
            return new List<string>();
        }
    }

    private string GetTeamString(object record)
    {
        try
        {
            // Get Team property (ModdedRoleTeams enum)
            var teamProperty = record.GetType().GetProperty("Team");
            if (teamProperty == null) return "Unknown";

            var team = teamProperty.GetValue(record);
            if (team == null) return "Unknown";

            return team.ToString() ?? "Unknown";
        }
        catch
        {
            return "Unknown";
        }
    }

    private T GetPropertyValue<T>(object obj, string propertyName)
    {
        try
        {
            var property = obj?.GetType().GetProperty(propertyName);
            if (property == null) return default!;

            var value = property.GetValue(obj);
            if (value == null) return default!;

            return (T)value;
        }
        catch
        {
            return default!;
        }
    }

    private string StripColorTags(string text)
    {
        if (string.IsNullOrEmpty(text)) return text;

        text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", "");
        text = text.Replace("</color>", "");
        text = text.Replace("<b>", "").Replace("</b>", "");
        text = text.Replace("<i>", "").Replace("</i>", "");

        return text.Trim();
    }
}

4. Reflection Cache

File: Reflection/ReflectionCache.cs

using System;
using System.Reflection;

namespace TownOfUsStatsExporter.Reflection;

/// <summary>
/// Cache for reflection metadata to improve performance.
/// Reflection is ~100x slower than direct access, so caching is essential.
/// </summary>
internal class ReflectionCache
{
    // Types
    public Type? EndGamePatchesType { get; set; }
    public Type? EndGameDataType { get; set; }
    public Type? PlayerRecordType { get; set; }
    public Type? GameHistoryType { get; set; }

    // Properties
    public PropertyInfo? PlayerRecordsProperty { get; set; }
    public PropertyInfo? PlayerStatsProperty { get; set; }
    public PropertyInfo? RoleHistoryProperty { get; set; }
    public PropertyInfo? KilledPlayersProperty { get; set; }
    public PropertyInfo? WinningFactionProperty { get; set; }

    // Methods (if needed)
    public MethodInfo? GetRoleNameMethod { get; set; }
}

5. Version Compatibility

File: Reflection/VersionCompatibility.cs

using System;
using System.Collections.Generic;

namespace TownOfUsStatsExporter.Reflection;

/// <summary>
/// Manages version compatibility checks for TOU Mira
/// </summary>
public static class VersionCompatibility
{
    // Known compatible versions
    private static readonly HashSet<string> TestedVersions = new()
    {
        "1.2.1",
        "1.2.0",
    };

    // Known incompatible versions
    private static readonly HashSet<string> IncompatibleVersions = new()
    {
        // Add any known incompatible versions here
    };

    public static string CheckVersion(string? version)
    {
        if (string.IsNullOrEmpty(version))
            return "Unsupported: Version unknown";

        // Parse version
        if (!Version.TryParse(version, out var parsedVersion))
            return $"Unsupported: Cannot parse version '{version}'";

        // Check if explicitly incompatible
        if (IncompatibleVersions.Contains(version))
            return $"Unsupported: Version {version} is known to be incompatible";

        // Check if tested
        if (TestedVersions.Contains(version))
            return $"Supported: Version {version} is tested and compatible";

        // Check if it's a newer minor/patch version
        foreach (var testedVersion in TestedVersions)
        {
            if (Version.TryParse(testedVersion, out var tested))
            {
                // Same major version = probably compatible
                if (parsedVersion.Major == tested.Major)
                {
                    return $"Probably Compatible: Version {version} (tested with {testedVersion})";
                }
            }
        }

        return $"Unsupported: Version {version} has not been tested";
    }

    public static void AddTestedVersion(string version)
    {
        TestedVersions.Add(version);
    }

    public static void AddIncompatibleVersion(string version)
    {
        IncompatibleVersions.Add(version);
    }
}

6. IL2CPP Helper

File: Reflection/IL2CPPHelper.cs

using System;
using System.Collections;
using System.Collections.Generic;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;

namespace TownOfUsStatsExporter.Reflection;

/// <summary>
/// Helper for converting IL2CPP types to managed types
/// </summary>
public static class IL2CPPHelper
{
    /// <summary>
    /// Convert IL2CPP list/collection to managed List
    /// </summary>
    public static List<object> ConvertToManagedList(object il2cppCollection)
    {
        var result = new List<object>();

        try
        {
            // Try as IEnumerable
            if (il2cppCollection is IEnumerable enumerable)
            {
                foreach (var item in enumerable)
                {
                    if (item != null)
                        result.Add(item);
                }
                return result;
            }

            // Try as Il2CppSystem.Collections.Generic.List<T>
            var listType = il2cppCollection.GetType();
            var countProperty = listType.GetProperty("Count");

            if (countProperty != null)
            {
                var count = (int)countProperty.GetValue(il2cppCollection)!;
                var getItemMethod = listType.GetMethod("get_Item") ?? listType.GetMethod("Get");

                if (getItemMethod != null)
                {
                    for (int i = 0; i < count; i++)
                    {
                        var item = getItemMethod.Invoke(il2cppCollection, new object[] { i });
                        if (item != null)
                            result.Add(item);
                    }
                }
            }
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error converting IL2CPP collection: {ex}");
        }

        return result;
    }

    /// <summary>
    /// Convert IL2CPP dictionary to managed Dictionary
    /// </summary>
    public static Dictionary<TKey, TValue> ConvertToManagedDictionary<TKey, TValue>(object il2cppDictionary)
        where TKey : notnull
    {
        var result = new Dictionary<TKey, TValue>();

        try
        {
            if (il2cppDictionary is IDictionary dict)
            {
                foreach (DictionaryEntry entry in dict)
                {
                    if (entry.Key is TKey key && entry.Value is TValue value)
                    {
                        result[key] = value;
                    }
                }
            }
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error converting IL2CPP dictionary: {ex}");
        }

        return result;
    }
}

7. Data Models

File: Models/ReflectedData.cs

namespace TownOfUsStatsExporter.Models;

/// <summary>
/// DTO for player record data extracted via reflection
/// </summary>
public class PlayerRecordData
{
    public string PlayerName { get; set; } = string.Empty;
    public string RoleString { get; set; } = string.Empty;
    public bool Winner { get; set; }
    public byte PlayerId { get; set; }
    public string TeamString { get; set; } = string.Empty;
}

/// <summary>
/// DTO for player stats data extracted via reflection
/// </summary>
public class PlayerStatsData
{
    public int CorrectKills { get; set; }
    public int IncorrectKills { get; set; }
    public int CorrectAssassinKills { get; set; }
    public int IncorrectAssassinKills { get; set; }
}

/// <summary>
/// DTO for killed player data
/// </summary>
public class KilledPlayerData
{
    public byte KillerId { get; set; }
    public byte VictimId { get; set; }
}

File: Models/GameStatsData.cs

using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace TownOfUsStatsExporter.Models;

// Same as in main implementation plan
public class GameStatsData
{
    [JsonPropertyName("token")]
    public string Token { get; set; } = string.Empty;

    [JsonPropertyName("secret")]
    public string? Secret { get; set; }

    [JsonPropertyName("gameInfo")]
    public GameInfoData GameInfo { get; set; } = new();

    [JsonPropertyName("players")]
    public List<PlayerExportData> Players { get; set; } = new();

    [JsonPropertyName("gameResult")]
    public GameResultData GameResult { get; set; } = new();
}

public class GameInfoData
{
    [JsonPropertyName("gameId")]
    public string GameId { get; set; } = string.Empty;

    [JsonPropertyName("timestamp")]
    public DateTime Timestamp { get; set; }

    [JsonPropertyName("lobbyCode")]
    public string LobbyCode { get; set; } = string.Empty;

    [JsonPropertyName("gameMode")]
    public string GameMode { get; set; } = string.Empty;

    [JsonPropertyName("duration")]
    public float Duration { get; set; }

    [JsonPropertyName("map")]
    public string Map { get; set; } = string.Empty;
}

public class PlayerExportData
{
    [JsonPropertyName("playerId")]
    public int PlayerId { get; set; }

    [JsonPropertyName("playerName")]
    public string PlayerName { get; set; } = string.Empty;

    [JsonPropertyName("playerTag")]
    public string? PlayerTag { get; set; }

    [JsonPropertyName("platform")]
    public string Platform { get; set; } = "Unknown";

    [JsonPropertyName("role")]
    public string Role { get; set; } = string.Empty;

    [JsonPropertyName("roles")]
    public List<string> Roles { get; set; } = new();

    [JsonPropertyName("modifiers")]
    public List<string> Modifiers { get; set; } = new();

    [JsonPropertyName("isWinner")]
    public bool IsWinner { get; set; }

    [JsonPropertyName("stats")]
    public PlayerStatsNumbers Stats { get; set; } = new();
}

public class PlayerStatsNumbers
{
    [JsonPropertyName("totalTasks")]
    public int TotalTasks { get; set; }

    [JsonPropertyName("tasksCompleted")]
    public int TasksCompleted { get; set; }

    [JsonPropertyName("kills")]
    public int Kills { get; set; }

    [JsonPropertyName("correctKills")]
    public int CorrectKills { get; set; }

    [JsonPropertyName("incorrectKills")]
    public int IncorrectKills { get; set; }

    [JsonPropertyName("correctAssassinKills")]
    public int CorrectAssassinKills { get; set; }

    [JsonPropertyName("incorrectAssassinKills")]
    public int IncorrectAssassinKills { get; set; }
}

public class GameResultData
{
    [JsonPropertyName("winningTeam")]
    public string WinningTeam { get; set; } = "Unknown";
}

8. Data Transformer

File: Export/DataTransformer.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
using UnityEngine;

namespace TownOfUsStatsExporter.Export;

/// <summary>
/// Transforms reflected TOU Mira data into export format
/// </summary>
public static class DataTransformer
{
    private static readonly Dictionary<byte, string> MapNames = new()
    {
        { 0, "The Skeld" },
        { 1, "MIRA HQ" },
        { 2, "Polus" },
        { 3, "Airship" },
        { 4, "The Fungle" },
        { 5, "Submerged" }
    };

    public static GameStatsData TransformToExportFormat(
        List<PlayerRecordData> playerRecords,
        Dictionary<byte, PlayerStatsData> playerStats,
        Dictionary<byte, List<string>> roleHistory,
        List<KilledPlayerData> killedPlayers,
        string winningFaction,
        string apiToken,
        string? secret)
    {
        var gameData = new GameStatsData
        {
            Token = apiToken,
            Secret = secret,
            GameInfo = BuildGameInfo(),
            GameResult = new GameResultData
            {
                WinningTeam = DetermineWinningTeam(winningFaction, playerRecords)
            }
        };

        // Transform each player
        foreach (var record in playerRecords)
        {
            try
            {
                var playerData = TransformPlayerData(record, playerStats, roleHistory, killedPlayers);
                gameData.Players.Add(playerData);
            }
            catch (Exception ex)
            {
                TownOfUsStatsPlugin.Logger.LogError($"Error transforming player {record.PlayerName}: {ex}");
            }
        }

        return gameData;
    }

    private static GameInfoData BuildGameInfo()
    {
        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,
            Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0))
        };
    }

    private static PlayerExportData TransformPlayerData(
        PlayerRecordData record,
        Dictionary<byte, PlayerStatsData> playerStats,
        Dictionary<byte, List<string>> roleHistory,
        List<KilledPlayerData> killedPlayers)
    {
        var player = PlayerControl.AllPlayerControls.ToArray()
            .FirstOrDefault(p => p.PlayerId == record.PlayerId);

        // Get role history for this player
        var roles = roleHistory.GetValueOrDefault(record.PlayerId, new List<string>());
        var lastRole = roles.LastOrDefault() ?? "Unknown";

        // Get stats
        var stats = playerStats.GetValueOrDefault(record.PlayerId, new PlayerStatsData());

        // Count kills
        var kills = killedPlayers.Count(k => k.KillerId == record.PlayerId && k.VictimId != record.PlayerId);

        // Get modifiers
        var bridge = ReflectionBridgeProvider.GetBridge();
        var modifiers = bridge.GetPlayerModifiers(record.PlayerId);

        // Get task info
        int totalTasks = 0;
        int completedTasks = 0;
        if (player != null)
        {
            totalTasks = player.Data.Tasks.Count;
            completedTasks = player.Data.Tasks.Count(t => t.Complete);
        }

        return new PlayerExportData
        {
            PlayerId = record.PlayerId,
            PlayerName = StripColorTags(record.PlayerName),
            PlayerTag = GetPlayerTag(player),
            Platform = GetPlayerPlatform(player),
            Role = lastRole,
            Roles = roles,
            Modifiers = modifiers,
            IsWinner = record.Winner,
            Stats = new PlayerStatsNumbers
            {
                TotalTasks = totalTasks,
                TasksCompleted = completedTasks,
                Kills = kills,
                CorrectKills = stats.CorrectKills,
                IncorrectKills = stats.IncorrectKills,
                CorrectAssassinKills = stats.CorrectAssassinKills,
                IncorrectAssassinKills = stats.IncorrectAssassinKills
            }
        };
    }

    private static string DetermineWinningTeam(string winningFaction, List<PlayerRecordData> playerRecords)
    {
        // Use WinningFaction from GameHistory if available
        if (!string.IsNullOrEmpty(winningFaction))
            return winningFaction;

        // Fallback: Check first winner's team
        var winner = playerRecords.FirstOrDefault(r => r.Winner);
        if (winner == null) return "Unknown";

        return winner.TeamString switch
        {
            "Crewmate" => "Crewmates",
            "Impostor" => "Impostors",
            "Neutral" => "Neutrals",
            "Custom" => "Custom",
            _ => "Unknown"
        };
    }

    private static string GetMapName(byte mapId)
    {
        return MapNames.TryGetValue(mapId, out var name) ? name : $"Unknown Map ({mapId})";
    }

    private static string StripColorTags(string text)
    {
        if (string.IsNullOrEmpty(text)) return text;
        text = Regex.Replace(text, @"<color=#[A-Fa-f0-9]+>", "");
        text = text.Replace("</color>", "");
        return text.Trim();
    }

    private static string? GetPlayerTag(PlayerControl? player)
    {
        if (player?.Data?.FriendCode != null && !string.IsNullOrEmpty(player.Data.FriendCode))
            return player.Data.FriendCode;
        return null;
    }

    private static string GetPlayerPlatform(PlayerControl? player)
    {
        if (player?.Data?.Platform != null)
            return player.Data.Platform.ToString();
        return "Unknown";
    }
}

9. Stats Exporter

File: Export/StatsExporter.cs

using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using TownOfUsStatsExporter.Config;
using TownOfUsStatsExporter.Models;

namespace TownOfUsStatsExporter.Export;

/// <summary>
/// Main orchestrator for stats export process
/// </summary>
public static class StatsExporter
{
    public static async Task ExportGameStatsAsync()
    {
        try
        {
            TownOfUsStatsPlugin.Logger.LogInfo("=== Starting Game Stats Export ===");

            // Read configuration
            var config = await ApiConfigManager.ReadConfigAsync();

            if (!config.EnableApiExport)
            {
                TownOfUsStatsPlugin.Logger.LogInfo("API export is disabled - skipping");
                return;
            }

            if (!config.IsValid())
            {
                TownOfUsStatsPlugin.Logger.LogWarning("API configuration is incomplete - skipping export");
                return;
            }

            // Get data from TOU Mira via reflection
            var bridge = ReflectionBridgeProvider.GetBridge();

            var playerRecords = bridge.GetPlayerRecords();
            if (playerRecords.Count == 0)
            {
                TownOfUsStatsPlugin.Logger.LogWarning("No player data available - skipping export");
                return;
            }

            var playerStats = bridge.GetPlayerStats();
            var roleHistory = bridge.GetRoleHistory();
            var killedPlayers = bridge.GetKilledPlayers();
            var winningFaction = bridge.GetWinningFaction();

            TownOfUsStatsPlugin.Logger.LogInfo($"Collected data: {playerRecords.Count} players, {playerStats.Count} stats entries");

            // Transform to export format
            var gameData = DataTransformer.TransformToExportFormat(
                playerRecords,
                playerStats,
                roleHistory,
                killedPlayers,
                winningFaction,
                config.ApiToken!,
                config.Secret
            );

            TownOfUsStatsPlugin.Logger.LogInfo($"Transformed data: {gameData.Players.Count} players ready for export");

            // Save local backup if enabled
            if (config.SaveLocalBackup)
            {
                await SaveLocalBackupAsync(gameData);
            }

            // Send to API
            await ApiClient.SendToApiAsync(gameData, config.ApiEndpoint!);

            TownOfUsStatsPlugin.Logger.LogInfo("=== Game Stats Export Completed Successfully ===");
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error during stats export: {ex}");
        }
    }

    private static async Task SaveLocalBackupAsync(GameStatsData data)
    {
        try
        {
            var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
            var logFolder = Path.Combine(documentsPath, "TownOfUs", "GameLogs");
            Directory.CreateDirectory(logFolder);

            var gameIdShort = data.GameInfo.GameId.Substring(0, 8);
            var fileName = $"Game_{DateTime.Now:yyyyMMdd_HHmmss}_{gameIdShort}.json";
            var filePath = Path.Combine(logFolder, fileName);

            var jsonOptions = new JsonSerializerOptions
            {
                WriteIndented = true,
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
            };

            var jsonData = JsonSerializer.Serialize(data, jsonOptions);
            await File.WriteAllTextAsync(filePath, jsonData, Encoding.UTF8);

            TownOfUsStatsPlugin.Logger.LogInfo($"Local backup saved: {filePath}");
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Failed to save local backup: {ex}");
        }
    }
}

10. API Client

File: Export/ApiClient.cs

using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using TownOfUsStatsExporter.Models;

namespace TownOfUsStatsExporter.Export;

/// <summary>
/// HTTP client for sending data to API
/// </summary>
public static class ApiClient
{
    private static readonly HttpClient httpClient = new()
    {
        Timeout = TimeSpan.FromSeconds(30)
    };

    public static async Task SendToApiAsync(GameStatsData data, string endpoint)
    {
        try
        {
            TownOfUsStatsPlugin.Logger.LogInfo($"Sending data to API: {endpoint}");

            var jsonOptions = new JsonSerializerOptions
            {
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                WriteIndented = false,
                DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
            };

            var jsonData = JsonSerializer.Serialize(data, jsonOptions);
            var content = new StringContent(jsonData, Encoding.UTF8, "application/json");

            httpClient.DefaultRequestHeaders.Clear();
            httpClient.DefaultRequestHeaders.Add("User-Agent",
                $"TownOfUs-StatsExporter/{TownOfUsStatsPlugin.PluginVersion}");

            var response = await httpClient.PostAsync(endpoint, content);

            if (response.IsSuccessStatusCode)
            {
                var responseContent = await response.Content.ReadAsStringAsync();
                TownOfUsStatsPlugin.Logger.LogInfo($"API response: {responseContent}");
            }
            else
            {
                var errorContent = await response.Content.ReadAsStringAsync();
                TownOfUsStatsPlugin.Logger.LogError($"API returned error: {response.StatusCode} - {errorContent}");
            }
        }
        catch (HttpRequestException httpEx)
        {
            TownOfUsStatsPlugin.Logger.LogError($"HTTP error sending to API: {httpEx.Message}");
        }
        catch (TaskCanceledException)
        {
            TownOfUsStatsPlugin.Logger.LogError("API request timeout (30 seconds exceeded)");
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Unexpected error sending to API: {ex.Message}");
        }
    }
}

11. Config Manager

File: Config/ApiConfigManager.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace TownOfUsStatsExporter.Config;

public static class ApiConfigManager
{
    private const string ConfigFileName = "ApiSet.ini";

    public static async Task<ApiConfig> ReadConfigAsync()
    {
        var config = new ApiConfig();

        try
        {
            foreach (var configPath in GetConfigSearchPaths())
            {
                if (File.Exists(configPath))
                {
                    TownOfUsStatsPlugin.Logger.LogInfo($"Reading config from: {configPath}");
                    var lines = await File.ReadAllLinesAsync(configPath);
                    config = ParseIniFile(lines);
                    TownOfUsStatsPlugin.Logger.LogInfo($"Config loaded: EnableExport={config.EnableApiExport}");
                    return config;
                }
            }

            // No config found - create default
            var defaultPath = GetConfigSearchPaths().Last();
            await CreateDefaultConfigAsync(defaultPath);
            TownOfUsStatsPlugin.Logger.LogWarning($"Config file created at: {defaultPath}");
        }
        catch (Exception ex)
        {
            TownOfUsStatsPlugin.Logger.LogError($"Error reading config: {ex.Message}");
        }

        return config;
    }

    private static IEnumerable<string> GetConfigSearchPaths()
    {
        // 1. Game directory
        var gameDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
        yield return Path.Combine(gameDirectory!, ConfigFileName);

        // 2. Documents/TownOfUs
        var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
        var touFolder = Path.Combine(documentsPath, "TownOfUs");
        Directory.CreateDirectory(touFolder);
        yield return Path.Combine(touFolder, ConfigFileName);
    }

    private static ApiConfig ParseIniFile(string[] lines)
    {
        var config = new ApiConfig();

        foreach (var line in lines)
        {
            if (string.IsNullOrWhiteSpace(line) || line.Trim().StartsWith("#") || line.Trim().StartsWith(";"))
                continue;

            var parts = line.Split('=', 2);
            if (parts.Length != 2) continue;

            var key = parts[0].Trim();
            var value = parts[1].Trim();

            switch (key.ToLowerInvariant())
            {
                case "enableapiexport":
                    config.EnableApiExport = bool.TryParse(value, out var enable) && enable;
                    break;

                case "apitoken":
                    if (!string.IsNullOrWhiteSpace(value) && value != "null")
                        config.ApiToken = value;
                    break;

                case "apiendpoint":
                    if (!string.IsNullOrWhiteSpace(value) && value != "null")
                        config.ApiEndpoint = value;
                    break;

                case "savelocalbackup":
                    config.SaveLocalBackup = bool.TryParse(value, out var save) && save;
                    break;

                case "secret":
                    if (!string.IsNullOrWhiteSpace(value) && value != "null")
                        config.Secret = value;
                    break;
            }
        }

        return config;
    }

    private static async Task CreateDefaultConfigAsync(string configPath)
    {
        var defaultConfig = @"# TownOfUs Stats 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
";

        await File.WriteAllTextAsync(configPath, defaultConfig);
    }
}

File: Config/ApiConfig.cs

namespace TownOfUsStatsExporter.Config;

public class ApiConfig
{
    public bool EnableApiExport { get; set; } = false;
    public string? ApiToken { get; set; } = null;
    public string? ApiEndpoint { get; set; } = null;
    public bool SaveLocalBackup { get; set; } = false;
    public string? Secret { get; set; } = null;

    public bool IsValid()
    {
        return EnableApiExport
               && !string.IsNullOrWhiteSpace(ApiToken)
               && !string.IsNullOrWhiteSpace(ApiEndpoint);
    }
}

Version Compatibility

Supported TOU Mira Versions

Version Status Notes
1.2.1 Tested Fully compatible
1.2.0 Tested Fully compatible
1.3.x ⚠️ Untested Should work (same major)
2.0.x Unknown May require updates

Breaking Change Detection

Plugin will log warnings if:

  • Required types not found
  • Required properties not found
  • Version is not in tested list

Update Strategy

When TOU Mira updates:

  1. Test Compatibility

    # Run game with new TOU Mira version
    # Check BepInEx logs for errors
    
  2. If Compatible

    // Add to VersionCompatibility.cs
    VersionCompatibility.AddTestedVersion("1.2.2");
    
  3. If Incompatible

    • Update reflection code
    • Test all functionality
    • Release new version
    • Document changes

Testing Strategy

Unit Tests (Manual)

Test 1: Plugin Loading

1. Install plugin DLL
2. Start game
3. Check BepInEx console for:
   ✅ "TownOfUs Stats Exporter v1.0.0"
   ✅ "Successfully connected to TOU Mira v..."
   ✅ "Harmony patches applied successfully"

Test 2: Data Collection

1. Play complete game (10 players)
2. At end screen, check logs for:
   ✅ "End Game Export Patch Triggered"
   ✅ "Retrieved X player records"
   ✅ "Retrieved stats for X players"
   ✅ "Retrieved role history for X players"

Test 3: API Export

1. Configure ApiSet.ini with valid endpoint
2. Play game
3. Check logs for:
   ✅ "Sending data to API: ..."
   ✅ "API response: ..."

Test 4: Local Backup

1. Set SaveLocalBackup=true
2. Play game
3. Check Documents/TownOfUs/GameLogs/
   ✅ JSON file exists
   ✅ JSON is valid
   ✅ Contains all players

Integration Tests

Test Scenario: Role Changes

1. Play game with Amnesiac
2. Amnesiac remembers as Impostor
3. Check exported data has:
   "roles": ["Amnesiac", "Impostor"]

Test Scenario: Modifiers

1. Play game with modifiers enabled
2. Check exported data has modifiers listed

Test Scenario: Special Wins

1. Play game where Jester gets voted
2. Check winning team determination

Deployment Guide

For Developers

Building:

dotnet build -c Release

Output:

bin/Release/netstandard2.1/TownOfUsStatsExporter.dll

For Users

Installation:

1. Download TownOfUsStatsExporter.dll
2. Copy to: Among Us/BepInEx/plugins/
3. Start game once to generate ApiSet.ini
4. Edit ApiSet.ini with your configuration
5. Restart game

Configuration:

# Documents/TownOfUs/ApiSet.ini
EnableApiExport=true
ApiToken=your_token_here
ApiEndpoint=https://api.example.com/api/among-data
SaveLocalBackup=true
Secret=your_secret

Verification:

1. Start game
2. Open BepInEx console (F10)
3. Look for: "TownOfUs Stats Exporter v1.0.0"
4. Should say: "Successfully connected to TOU Mira"

Troubleshooting

Problem: Plugin Not Loading

Symptoms:

  • No log messages from Stats Exporter
  • DLL not in BepInEx/plugins/

Solutions:

  1. Verify DLL is in correct location
  2. Check BepInEx is installed
  3. Check for conflicting plugins

Problem: "Failed to initialize reflection bridge"

Symptoms:

[Error] Failed to initialize TOU Mira reflection bridge!
[Error] Type not found: TownOfUs.Patches.EndGamePatches

Solutions:

  1. Update to compatible TOU Mira version
  2. Check TOU Mira is actually loaded
  3. Update stats exporter to latest version

Problem: No Data Exported

Symptoms:

  • Game ends but no export happens
  • Logs show "No player data available"

Solutions:

  1. Check game mode (Hide & Seek skipped)
  2. Wait longer (export is async)
  3. Check EnableApiExport=true in config

Problem: API Errors

Symptoms:

[Error] API returned error: 401 - Unauthorized

Solutions:

  1. Check ApiToken is correct
  2. Check ApiEndpoint is correct
  3. Check API is accessible
  4. Verify Secret matches server

Limitations

What Works

  • Read public classes and properties
  • Access EndGameData.PlayerRecords
  • Access GameHistory (RoleHistory, PlayerStats, KilledPlayers)
  • Get player modifiers
  • Extract role names from RoleBehaviour
  • Strip color tags
  • Export to JSON
  • Send to API
  • Save local backups

What Doesn't Work

  • Access to internal/private members
  • Direct type safety (everything via object)
  • Compile-time checking
  • Guaranteed compatibility across versions
  • Access to internal APIs/methods

Performance Impact

Operation Time (Reflection) Time (Direct) Overhead
Get PlayerRecords 5ms 0.05ms 100x
Get RoleHistory 15ms 0.1ms 150x
Get PlayerStats 2ms 0.02ms 100x
Total Data Collection ~22ms ~0.17ms ~130x
Export (async) ~1500ms N/A 0ms UI block

Impact: Negligible (runs once per game, async)


Conclusion

Feasibility: Możliwe

Plugin standalone jest możliwy do zaimplementowania bez modyfikacji TOU Mira.

Zalety

  • Zero zmian w kodzie TOU Mira
  • Całkowicie opcjonalny
  • Niezależny development cycle
  • Łatwa instalacja/deinstalacja

Wady

  • Kruche (łamie się przy zmianach TOU Mira)
  • Wolniejsze (~130x overhead dla reflection)
  • Wymaga ciągłego maintenance
  • Brak type safety
  • Trudniejszy debugging

Rekomendacja

Dla prototyping/testing: TAK Dla production long-term: ⚠️ Rozważ Hybrid approach


End of Pure Standalone Implementation