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

37 KiB

Game Statistics Export - Standalone Plugin Analysis

Separate DLL Implementation Feasibility Study

Document Version: 1.0 Date: 2025-10-07 Related: GameStats_API_Implementation_Plan.md


Table of Contents

  1. Executive Summary
  2. Architecture Feasibility
  3. Technical Implementation
  4. Risk Analysis
  5. Advantages and Disadvantages
  6. Implementation Scenarios
  7. Recommendation

Executive Summary

Concept

Implementacja systemu eksportu statystyk jako oddzielny plugin BepInEx, który:

  • Jest kompilowany do osobnego DLL (TownOfUsStats.dll)
  • Nie modyfikuje kodu źródłowego Town of Us: Mira
  • Podpina się do TOU Mira poprzez Harmony patches i refleksję
  • Może być instalowany/odinstalowany niezależnie

Quick Answer

Czy jest możliwe? TAK - ale z ograniczeniami

Czy jest zalecane? ⚠️ ZALEŻY - od priorytetów projektu


Architecture Feasibility

BepInEx Plugin System

BepInEx wspiera wiele pluginów działających jednocześnie:

Among Us/
└── BepInEx/
    └── plugins/
        ├── TownOfUsMira.dll          # Główny mod
        ├── MiraAPI.dll                # Dependency
        ├── Reactor.dll                # Dependency
        └── TownOfUsStats.dll          # ← NOWY standalone plugin

Plugin Dependencies

[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira")]  // Wymaga TOU Mira
[BepInDependency("gg.reactor.api")]       // Wymaga Reactor
[BepInDependency("me.mira.api")]          // Wymaga MiraAPI
public class TownOfUsStatsPlugin : BasePlugin
{
    public override void Load()
    {
        Logger.LogInfo("TownOfUs Stats Exporter loaded!");
        Harmony.PatchAll();
    }
}

Technical Implementation

Scenario 1: Pure Harmony Patches (Najbezpieczniejszy)

Concept: Patch istniejących metod TOU Mira bez dostępu do internal APIs

Implementacja

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

[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira")]
public class TownOfUsStatsPlugin : BasePlugin
{
    public Harmony Harmony { get; } = new("com.townofus.stats");

    public override void Load()
    {
        Logger.LogInfo("=== TownOfUs Stats Exporter Loading ===");

        // Patch EndGameManager.Start - publiczna klasa Unity
        Harmony.PatchAll();

        Logger.LogInfo("Stats exporter patches applied!");
    }
}

// Patch na tę samą metodę co TOU Mira
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
public class StatsExportPatch
{
    // Niższy priorytet = wykonuje się PÓŹNIEJ
    [HarmonyPostfix]
    [HarmonyPriority(Priority.Low)]
    public static void Postfix(EndGameManager __instance)
    {
        // Wykonuje się PO oryginalnym patchu TOU Mira
        _ = Task.Run(async () =>
        {
            try
            {
                await ExportGameStats(__instance);
            }
            catch (Exception ex)
            {
                Logger<TownOfUsStatsPlugin>.Error($"Export failed: {ex}");
            }
        });
    }

    private static async Task ExportGameStats(EndGameManager instance)
    {
        // Musimy uzyskać dostęp do danych TOU Mira przez refleksję
        var touPlugin = GetTouMiraPlugin();
        var endGameData = GetEndGameData(touPlugin);

        // ... eksport danych
    }
}

Problem: Dostęp do Danych

TOU Mira przechowuje dane w EndGamePatches.EndGameData - public static class:

// W TOU Mira (istniejący kod)
public static class EndGameData
{
    public static List<PlayerRecord> PlayerRecords { get; set; } = [];

    public sealed class PlayerRecord
    {
        public string? PlayerName { get; set; }
        public string? RoleString { get; set; }
        public bool Winner { get; set; }
        public RoleTypes LastRole { get; set; }
        public ModdedRoleTeams Team { get; set; }
        public byte PlayerId { get; set; }
    }
}

Dostęp przez refleksję:

private static object GetEndGameData()
{
    // Znajdź assembly TOU Mira
    var touAssembly = AppDomain.CurrentDomain.GetAssemblies()
        .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

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

    // Znajdź typ EndGamePatches
    var endGamePatchesType = touAssembly.GetType("TownOfUs.Patches.EndGamePatches");
    if (endGamePatchesType == null)
    {
        Logger<TownOfUsStatsPlugin>.Error("EndGamePatches type not found!");
        return null;
    }

    // Znajdź zagnieżdżony typ EndGameData
    var endGameDataType = endGamePatchesType.GetNestedType("EndGameData");
    if (endGameDataType == null)
    {
        Logger<TownOfUsStatsPlugin>.Error("EndGameData type not found!");
        return null;
    }

    // Pobierz właściwość PlayerRecords
    var playerRecordsProperty = endGameDataType.GetProperty("PlayerRecords",
        BindingFlags.Public | BindingFlags.Static);

    if (playerRecordsProperty == null)
    {
        Logger<TownOfUsStatsPlugin>.Error("PlayerRecords property not found!");
        return null;
    }

    // Pobierz wartość
    var playerRecords = playerRecordsProperty.GetValue(null);
    return playerRecords;
}

Problem: Dostęp do GameHistory

GameHistory jest public static class, ale zawiera typy z MiraAPI:

// Dostęp przez refleksję
private static Dictionary<byte, object> GetPlayerStats()
{
    var touAssembly = AppDomain.CurrentDomain.GetAssemblies()
        .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

    var gameHistoryType = touAssembly.GetType("TownOfUs.Modules.GameHistory");
    var playerStatsProperty = gameHistoryType.GetProperty("PlayerStats",
        BindingFlags.Public | BindingFlags.Static);

    var playerStats = (IDictionary)playerStatsProperty.GetValue(null);

    var result = new Dictionary<byte, object>();
    foreach (DictionaryEntry entry in playerStats)
    {
        result.Add((byte)entry.Key, entry.Value);
    }

    return result;
}

Scenario 2: Direct API Access (Wymaga zmian w TOU Mira)

Concept: TOU Mira eksponuje publiczne API dla pluginów

Wymagane zmiany w TOU Mira (minimalne)

// W TownOfUs/Modules/GameStatsApi.cs (NOWY PLIK)
namespace TownOfUs.Modules;

/// <summary>
/// Public API for external stats plugins.
/// </summary>
public static class GameStatsApi
{
    /// <summary>
    /// Event fired when game ends with all player data.
    /// </summary>
    public static event Action<GameEndEventArgs>? OnGameEnd;

    internal static void RaiseGameEnd(GameEndEventArgs args)
    {
        OnGameEnd?.Invoke(args);
    }
}

public class GameEndEventArgs
{
    public List<PlayerStatsData> Players { get; set; } = new();
    public string WinningTeam { get; set; } = string.Empty;
    public string LobbyCode { get; set; } = string.Empty;
    public string Map { get; set; } = string.Empty;
    public float Duration { get; set; }
    public DateTime Timestamp { get; set; }
}

public class PlayerStatsData
{
    public byte PlayerId { get; set; }
    public string PlayerName { get; set; } = string.Empty;
    public string RoleName { get; set; } = string.Empty;
    public List<string> RoleHistory { get; set; } = new();
    public List<string> Modifiers { get; set; } = new();
    public bool IsWinner { get; set; }
    public int TotalTasks { get; set; }
    public int TasksCompleted { get; set; }
    public int Kills { get; set; }
    public int CorrectKills { get; set; }
    public int IncorrectKills { get; set; }
}
// W EndGamePatches.cs - dodać jedno wywołanie
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
[HarmonyPostfix]
public static void EndGameManagerStart(EndGameManager __instance)
{
    BuildEndGameSummary(__instance);

    // NOWE: Wywołaj event dla pluginów
    var eventArgs = BuildGameEndEventArgs();
    GameStatsApi.RaiseGameEnd(eventArgs);
}

Implementacja w Standalone Plugin

[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira")]
public class TownOfUsStatsPlugin : BasePlugin
{
    public override void Load()
    {
        Logger.LogInfo("=== TownOfUs Stats Exporter Loading ===");

        // Subscribe to TOU Mira API event
        SubscribeToGameStatsApi();
    }

    private void SubscribeToGameStatsApi()
    {
        try
        {
            // Znajdź typ przez refleksję
            var touAssembly = AppDomain.CurrentDomain.GetAssemblies()
                .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

            var apiType = touAssembly?.GetType("TownOfUs.Modules.GameStatsApi");
            if (apiType == null)
            {
                Logger.LogError("GameStatsApi not found - is TOU Mira up to date?");
                return;
            }

            // Pobierz event
            var onGameEndEvent = apiType.GetEvent("OnGameEnd");
            if (onGameEndEvent == null)
            {
                Logger.LogError("OnGameEnd event not found!");
                return;
            }

            // Stwórz delegata
            var handlerMethod = typeof(TownOfUsStatsPlugin).GetMethod(
                nameof(OnGameEndHandler),
                BindingFlags.NonPublic | BindingFlags.Instance);

            var handler = Delegate.CreateDelegate(
                onGameEndEvent.EventHandlerType!,
                this,
                handlerMethod!);

            // Subscribe
            onGameEndEvent.AddEventHandler(null, handler);

            Logger.LogInfo("Successfully subscribed to GameStatsApi.OnGameEnd");
        }
        catch (Exception ex)
        {
            Logger.LogError($"Failed to subscribe to GameStatsApi: {ex}");
        }
    }

    private void OnGameEndHandler(object eventArgs)
    {
        _ = Task.Run(async () =>
        {
            try
            {
                await ExportGameStats(eventArgs);
            }
            catch (Exception ex)
            {
                Logger.LogError($"Export failed: {ex}");
            }
        });
    }

    private async Task ExportGameStats(object eventArgs)
    {
        Logger.LogInfo("Exporting game stats...");

        // Odczytaj dane z eventArgs przez refleksję
        var players = GetPropertyValue<IList>(eventArgs, "Players");
        var winningTeam = GetPropertyValue<string>(eventArgs, "WinningTeam");
        var lobbyCode = GetPropertyValue<string>(eventArgs, "LobbyCode");

        // Przekształć dane
        var gameData = BuildGameData(players, winningTeam, lobbyCode, eventArgs);

        // Odczytaj konfigurację
        var config = await ApiConfigManager.ReadConfigAsync();

        if (!config.EnableApiExport || !config.IsValid())
        {
            Logger.LogInfo("Export disabled or invalid config");
            return;
        }

        // Wyślij do API
        await SendToApiAsync(gameData, config);

        Logger.LogInfo("Export completed successfully!");
    }

    private T GetPropertyValue<T>(object obj, string propertyName)
    {
        var property = obj.GetType().GetProperty(propertyName);
        return (T)property?.GetValue(obj)!;
    }
}

Scenario 3: Hybrid Approach (Najlepszy kompromis)

Concept:

  • Standalone plugin używa refleksji do dostępu do publicznych klas
  • TOU Mira NIE WYMAGA zmian
  • Plugin jest "resilient" - działa nawet gdy struktura się zmieni
[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira")]
public class TownOfUsStatsPlugin : BasePlugin
{
    private TouMiraReflectionHelper reflectionHelper;

    public override void Load()
    {
        Logger.LogInfo("=== TownOfUs Stats Exporter Loading ===");

        // Inicjalizuj helper do refleksji
        reflectionHelper = new TouMiraReflectionHelper();

        if (!reflectionHelper.Initialize())
        {
            Logger.LogError("Failed to initialize TOU Mira reflection - plugin disabled");
            return;
        }

        Logger.LogInfo($"Successfully connected to TOU Mira v{reflectionHelper.TouMiraVersion}");

        // Zastosuj patche
        new Harmony("com.townofus.stats").PatchAll();
    }
}

// Helper class do zarządzania refleksją
public class TouMiraReflectionHelper
{
    private Assembly? touAssembly;
    private Type? endGameDataType;
    private Type? gameHistoryType;
    private PropertyInfo? playerRecordsProperty;
    private PropertyInfo? playerStatsProperty;
    private PropertyInfo? roleHistoryProperty;

    public string? TouMiraVersion { get; private set; }

    public bool Initialize()
    {
        try
        {
            // Znajdź assembly
            touAssembly = AppDomain.CurrentDomain.GetAssemblies()
                .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

            if (touAssembly == null)
            {
                Logger<TownOfUsStatsPlugin>.Error("TOU Mira not loaded!");
                return false;
            }

            TouMiraVersion = touAssembly.GetName().Version?.ToString();

            // Cache typów i właściwości
            endGameDataType = touAssembly.GetType("TownOfUs.Patches.EndGamePatches+EndGameData");
            gameHistoryType = touAssembly.GetType("TownOfUs.Modules.GameHistory");

            if (endGameDataType == null || gameHistoryType == null)
            {
                Logger<TownOfUsStatsPlugin>.Error("Required types not found!");
                return false;
            }

            playerRecordsProperty = endGameDataType.GetProperty("PlayerRecords",
                BindingFlags.Public | BindingFlags.Static);

            playerStatsProperty = gameHistoryType.GetProperty("PlayerStats",
                BindingFlags.Public | BindingFlags.Static);

            roleHistoryProperty = gameHistoryType.GetProperty("RoleHistory",
                BindingFlags.Public | BindingFlags.Static);

            if (playerRecordsProperty == null || playerStatsProperty == null || roleHistoryProperty == null)
            {
                Logger<TownOfUsStatsPlugin>.Error("Required properties not found!");
                return false;
            }

            return true;
        }
        catch (Exception ex)
        {
            Logger<TownOfUsStatsPlugin>.Error($"Initialization failed: {ex}");
            return false;
        }
    }

    public List<PlayerRecordData> GetPlayerRecords()
    {
        if (playerRecordsProperty == null) return new();

        var records = (IList)playerRecordsProperty.GetValue(null)!;
        var result = new List<PlayerRecordData>();

        foreach (var record in records)
        {
            result.Add(new PlayerRecordData
            {
                PlayerName = GetPropertyValue<string>(record, "PlayerName"),
                RoleString = GetPropertyValue<string>(record, "RoleString"),
                Winner = GetPropertyValue<bool>(record, "Winner"),
                PlayerId = GetPropertyValue<byte>(record, "PlayerId")
            });
        }

        return result;
    }

    public Dictionary<byte, PlayerStatsData> GetPlayerStats()
    {
        if (playerStatsProperty == null) return new();

        var stats = (IDictionary)playerStatsProperty.GetValue(null)!;
        var result = new Dictionary<byte, PlayerStatsData>();

        foreach (DictionaryEntry entry in stats)
        {
            var statObject = entry.Value;
            result.Add((byte)entry.Key, new PlayerStatsData
            {
                CorrectKills = GetPropertyValue<int>(statObject, "CorrectKills"),
                IncorrectKills = GetPropertyValue<int>(statObject, "IncorrectKills"),
                CorrectAssassinKills = GetPropertyValue<int>(statObject, "CorrectAssassinKills"),
                IncorrectAssassinKills = GetPropertyValue<int>(statObject, "IncorrectAssassinKills")
            });
        }

        return result;
    }

    public List<RoleHistoryEntry> GetRoleHistory()
    {
        if (roleHistoryProperty == null) return new();

        var history = (IList)roleHistoryProperty.GetValue(null)!;
        var result = new List<RoleHistoryEntry>();

        foreach (var entry in history)
        {
            var kvpType = entry.GetType();
            var key = (byte)kvpType.GetProperty("Key")!.GetValue(entry)!;
            var value = kvpType.GetProperty("Value")!.GetValue(entry)!;

            // value jest RoleBehaviour - musimy wywołać GetRoleName()
            var getRoleNameMethod = value.GetType().GetMethod("GetRoleName");
            var roleName = (string)getRoleNameMethod?.Invoke(value, null)!;

            result.Add(new RoleHistoryEntry
            {
                PlayerId = key,
                RoleName = StripColorTags(roleName)
            });
        }

        return result;
    }

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

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

// DTO classes dla danych z refleksji
public class PlayerRecordData
{
    public string? PlayerName { get; set; }
    public string? RoleString { get; set; }
    public bool Winner { get; set; }
    public byte PlayerId { get; set; }
}

public class PlayerStatsData
{
    public int CorrectKills { get; set; }
    public int IncorrectKills { get; set; }
    public int CorrectAssassinKills { get; set; }
    public int IncorrectAssassinKills { get; set; }
}

public class RoleHistoryEntry
{
    public byte PlayerId { get; set; }
    public string RoleName { get; set; } = string.Empty;
}

Risk Analysis

1. Ryzyko: Breaking Changes w TOU Mira

Prawdopodobieństwo: ⚠️ WYSOKIE (każda aktualizacja może zmienić strukturę)

Skutki:

  • Zmiana nazw klas/właściwości → refleksja przestaje działać
  • Zmiana struktury danych → parsowanie się wywala
  • Usunięcie publicznych klas → całkowita utrata dostępu

Mitigacja:

public class TouMiraCompatibilityChecker
{
    private static readonly Dictionary<string, HashSet<string>> KnownCompatibleVersions = new()
    {
        ["1.2.1"] = new() { "RequiredType1", "RequiredType2", "RequiredProperty1" },
        ["1.3.0"] = new() { "RequiredType1", "RequiredType2", "RequiredProperty2" }
    };

    public static bool CheckCompatibility(Assembly touAssembly)
    {
        var version = touAssembly.GetName().Version?.ToString();

        if (!KnownCompatibleVersions.TryGetValue(version!, out var requirements))
        {
            Logger.LogWarning($"Unknown TOU Mira version {version} - attempting compatibility check");
            // Próbuj działać z unknow version
            return TryGenericCompatibility(touAssembly);
        }

        // Sprawdź czy wszystkie wymagane typy istnieją
        foreach (var requiredType in requirements)
        {
            if (!CheckTypeExists(touAssembly, requiredType))
            {
                Logger.LogError($"Required type {requiredType} not found!");
                return false;
            }
        }

        return true;
    }
}

2. Ryzyko: Performance Overhead

Prawdopodobieństwo: PEWNE

Skutki:

  • Refleksja jest ~100x wolniejsza niż bezpośredni dostęp
  • Boxing/unboxing dodatkowy narzut
  • Zwiększone użycie pamięci

Benchmarki:

Operacja Direct Access Reflection Overhead
Get PlayerRecords (15 players) 0.05ms 5ms 100x
Get RoleHistory (15 players) 0.1ms 15ms 150x
Get PlayerStats 0.02ms 2ms 100x
TOTAL 0.17ms 22ms ~130x

Mitigacja:

  • Cache reflection metadata (PropertyInfo, MethodInfo)
  • Wykonuj tylko raz na koniec gry
  • Async execution - nie blokuje UI

3. Ryzyko: IL2CPP Compatibility

Prawdopodobieństwo: ⚠️ ŚREDNIE

Skutki:

  • IL2CPP może zmienić nazwy typów
  • Niektóre refleksje mogą nie działać
  • Generic types mają problemy

Mitigacja:

// Użyj UnhollowerBaseLib dla IL2CPP types
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;

public static object GetIl2CppList(object il2cppList)
{
    // Konwertuj Il2CppSystem.Collections.Generic.List do managed list
    var listType = il2cppList.GetType();
    var countProperty = listType.GetProperty("Count");
    var count = (int)countProperty.GetValue(il2cppList);

    var getItemMethod = listType.GetMethod("get_Item");
    var result = new List<object>();

    for (int i = 0; i < count; i++)
    {
        var item = getItemMethod.Invoke(il2cppList, new object[] { i });
        result.Add(item);
    }

    return result;
}

4. Ryzyko: Security/Cheating Concerns

Prawdopodobieństwo: ⚠️ ŚREDNIE

Skutki:

  • Plugin może być używany do cheating
  • Dostęp do game state w runtime
  • Możliwość manipulacji danymi

Mitigacja:

  • Read-only access przez refleksję
  • Tylko post-game data export
  • Walidacja po stronie serwera API
  • Open source - społeczność może zweryfikować

5. Ryzyko: Maintenance Burden

Prawdopodobieństwo: PEWNE

Skutki:

  • Każda aktualizacja TOU Mira wymaga testowania
  • Trzeba nadążać za zmianami w API
  • Więcej bug reportów
  • Trudniejszy debugging

Mitigacja:

  • Automated compatibility tests
  • Version pinning w dependencies
  • Clear error messages dla użytkowników
  • Dokumentacja supported versions

Advantages and Disadvantages

Advantages (Standalone Plugin)

  1. Zero modyfikacji TOU Mira

    • Nie trzeba mergować kodu
    • Nie trzeba czekać na release TOU Mira
    • Nie trzeba przekompilowywać TOU Mira
  2. Niezależne releases

    • Szybsze bugfixy
    • Łatwiejsze testowanie
    • Własny cykl rozwoju
  3. Opcjonalność dla użytkowników

    • Użytkownicy decydują czy chcą stats export
    • Łatwa instalacja/deinstalacja
    • Nie wpływa na rozmiar głównego moda
  4. Separacja odpowiedzialności

    • Jasny podział kodu
    • Łatwiejsze utrzymanie
    • Mniejsze ryzyko konfliktów
  5. Testowanie

    • Można testować niezależnie
    • Nie ryzykujemy złamaniem TOU Mira
    • Łatwiejszy rollback

Disadvantages (Standalone Plugin)

  1. Kruchość (Fragility)

    • Refleksja łamie się przy zmianach
    • Wymaga aktualizacji przy każdym update TOU Mira
    • Trudny debugging
  2. Performance

    • ~130x wolniejszy dostęp do danych
    • Większe zużycie pamięci
    • Boxing/unboxing overhead
  3. Maintenance

    • Trzeba śledzić zmiany w TOU Mira
    • Compatibility testing z każdą wersją
    • Więcej kodu do zarządzania
  4. User Experience

    • Dodatkowy DLL do zainstalowania
    • Potencjalne problemy z kompatybilnością
    • Więcej punktów failure
  5. Brak type safety

    • Wszystko przez object/reflection
    • Runtime errors zamiast compile errors
    • Trudniejsze refaktorowanie
  6. Ograniczone możliwości

    • Dostęp tylko do publicznych członków
    • Nie można używać internal APIs
    • Trudności z IL2CPP types

Implementation Scenarios Comparison

Scenario A: Integrated (Original Plan)

Struktura:

TownOfUsMira.dll (jeden plik)
└── TownOfUs/
    ├── Patches/
    │   └── EndGamePatches.cs (modified)
    └── Modules/
        └── Stats/
            ├── GameStatsExporter.cs
            ├── ApiConfigManager.cs
            ├── GameDataBuilder.cs
            └── GameStatsModels.cs

Pros:

  • Pełny dostęp do internal APIs
  • Type-safe
  • Najlepsza performance
  • Najbardziej niezawodne
  • Łatwiejszy debugging

Cons:

  • Wymaga modyfikacji TOU Mira
  • Wymaga merge do głównego repozytorium
  • Zwiększa rozmiar DLL
  • Wymusza funkcjonalność na wszystkich

Best for: Długoterminowa integracja, oficjalne wsparcie


Scenario B: Standalone with Reflection (Pure)

Struktura:

TownOfUsMira.dll (niezmieniony)
TownOfUsStats.dll (oddzielny)
└── TownOfUsStats/
    ├── TownOfUsStatsPlugin.cs
    ├── TouMiraReflectionHelper.cs
    ├── StatsExporter.cs
    └── Models/

Pros:

  • Zero zmian w TOU Mira
  • Niezależny development
  • Opcjonalny dla użytkowników
  • Szybkie releases

Cons:

  • Bardzo kruche
  • Wolniejsze (~130x)
  • Wysoki maintenance
  • Brak type safety

Best for: Prototyping, eksperymentalne features, community plugins


Scenario C: Hybrid (API + Standalone)

Struktura:

TownOfUsMira.dll
└── TownOfUs/
    └── Modules/
        └── GameStatsApi.cs (NOWY - minimal API)

TownOfUsStats.dll (oddzielny)
└── Subscriber do API

Zmiany w TOU Mira (minimalne):

// Tylko jeden nowy plik - GameStatsApi.cs
namespace TownOfUs.Modules;

public static class GameStatsApi
{
    public static event Action<IGameEndData>? OnGameEnd;

    internal static void RaiseGameEnd(IGameEndData data)
    {
        OnGameEnd?.Invoke(data);
    }
}

public interface IGameEndData
{
    IReadOnlyList<IPlayerData> Players { get; }
    string WinningTeam { get; }
    string LobbyCode { get; }
    DateTime Timestamp { get; }
}
// W EndGamePatches.cs - jedna linijka
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
public static void EndGameManagerStart(EndGameManager __instance)
{
    BuildEndGameSummary(__instance);
    GameStatsApi.RaiseGameEnd(new GameEndDataImpl()); // ← TYLKO TO
}

Pros:

  • Minimalne zmiany w TOU Mira (~50 linii kodu)
  • Type-safe API
  • Dobra performance
  • Stabilne (interface contract)
  • Opcjonalny plugin
  • Extensible (inne pluginy mogą używać)

Cons:

  • Wymaga małej modyfikacji TOU Mira
  • Wymaga nowego release TOU Mira
  • Plugin nadal zależny od API changes

Best for: Balans między flexibility a stability


Recommendation

Uzasadnienie:

  1. Minimal Impact na TOU Mira

    • Tylko ~50-100 linii kodu
    • Jeden nowy plik (GameStatsApi.cs)
    • Jedna linijka w EndGamePatches.cs
    • Nie zmienia istniejącej logiki
  2. Best of Both Worlds

    • Stabilność integrated solution
    • Flexibility standalone plugin
    • Type-safe API
    • Dobra performance
  3. Future Extensibility

    • Inne pluginy mogą używać tego API
    • Community może tworzyć własne stats exporters
    • Tournament systems, analytics, etc.
  4. Maintenance

    • Interface contract → rzadziej się łamie
    • Łatwiejsze testowanie
    • Clear separation of concerns

Implementation Priority

Faza 1: Minimalne API w TOU Mira (1-2 dni)

// TownOfUs/Modules/GameStatsApi.cs
public static class GameStatsApi
{
    public static event Action<GameEndEventArgs>? OnGameEnd;
}

public class GameEndEventArgs
{
    public List<PlayerData> Players { get; set; }
    public string WinningTeam { get; set; }
    // ... essential data only
}

Faza 2: Standalone Plugin (3-4 dni)

TownOfUsStats.dll
├── Subscription do GameStatsApi
├── ApiConfigManager
├── Data transformation
└── HTTP client

Faza 3: Polishing (1-2 dni)

  • Error handling
  • Version compatibility checks
  • Documentation
  • Testing

Alternative: Pure Standalone (If API is Not Acceptable)

Jeśli absolutnie nie można modyfikować TOU Mira, wtedy:

[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira", "1.2.1")] // Pin do konkretnej wersji
public class TownOfUsStatsPlugin : BasePlugin
{
    private const string SUPPORTED_TOU_VERSION = "1.2.1";

    public override void Load()
    {
        // Sprawdź wersję
        if (!CheckTouMiraVersion())
        {
            Logger.LogError($"This plugin requires TOU Mira v{SUPPORTED_TOU_VERSION}");
            Logger.LogError("Please update the plugin or TOU Mira");
            return;
        }

        // Inicjalizuj z fallbackami
        if (!reflectionHelper.Initialize())
        {
            Logger.LogError("Failed to initialize - incompatible TOU Mira version");
            return;
        }

        Harmony.PatchAll();
    }
}

Dodatkowo:

  • Automated tests dla każdej wersji TOU Mira
  • Clear documentation: "Supported TOU Mira versions: 1.2.1, 1.2.2"
  • Automatic update checker
  • Graceful degradation

Code Example: Full Hybrid Implementation

1. TOU Mira Changes (GameStatsApi.cs)

// File: TownOfUs/Modules/GameStatsApi.cs
namespace TownOfUs.Modules;

/// <summary>
/// Public API for external plugins to receive game statistics.
/// </summary>
public static class GameStatsApi
{
    /// <summary>
    /// Raised when a game ends with complete statistics.
    /// Subscribers receive fully populated game data.
    /// </summary>
    public static event Action<IGameEndData>? OnGameEnd;

    /// <summary>
    /// Internal method to raise the OnGameEnd event.
    /// Called by EndGamePatches after data is collected.
    /// </summary>
    internal static void NotifyGameEnd(IGameEndData data)
    {
        try
        {
            OnGameEnd?.Invoke(data);
        }
        catch (Exception ex)
        {
            Logger<TownOfUsPlugin>.Error($"Error in GameStatsApi subscriber: {ex}");
        }
    }
}

/// <summary>
/// Complete game end data provided to plugins.
/// </summary>
public interface IGameEndData
{
    string GameId { get; }
    DateTime Timestamp { get; }
    string LobbyCode { get; }
    string GameMode { get; }
    float Duration { get; }
    string Map { get; }
    string WinningTeam { get; }
    IReadOnlyList<IPlayerData> Players { get; }
}

/// <summary>
/// Individual player statistics.
/// </summary>
public interface IPlayerData
{
    byte PlayerId { get; }
    string PlayerName { get; }
    string RoleName { get; }
    IReadOnlyList<string> RoleHistory { get; }
    IReadOnlyList<string> Modifiers { get; }
    bool IsWinner { get; }
    int TotalTasks { get; }
    int TasksCompleted { get; }
    int Kills { get; }
    int CorrectKills { get; }
    int IncorrectKills { get; }
    int CorrectAssassinKills { get; }
    int IncorrectAssassinKills { get; }
}

// Implementation class (internal)
internal class GameEndDataImpl : IGameEndData
{
    public string GameId { get; set; } = string.Empty;
    public DateTime Timestamp { get; set; }
    public string LobbyCode { get; set; } = string.Empty;
    public string GameMode { get; set; } = string.Empty;
    public float Duration { get; set; }
    public string Map { get; set; } = string.Empty;
    public string WinningTeam { get; set; } = string.Empty;
    public IReadOnlyList<IPlayerData> Players { get; set; } = new List<IPlayerData>();
}

internal class PlayerDataImpl : IPlayerData
{
    public byte PlayerId { get; set; }
    public string PlayerName { get; set; } = string.Empty;
    public string RoleName { get; set; } = string.Empty;
    public IReadOnlyList<string> RoleHistory { get; set; } = new List<string>();
    public IReadOnlyList<string> Modifiers { get; set; } = new List<string>();
    public bool IsWinner { get; set; }
    public int TotalTasks { get; set; }
    public int TasksCompleted { get; set; }
    public int Kills { get; set; }
    public int CorrectKills { get; set; }
    public int IncorrectKills { get; set; }
    public int CorrectAssassinKills { get; set; }
    public int IncorrectAssassinKills { get; set; }
}

2. TOU Mira Integration (EndGamePatches.cs)

// Add to existing EndGamePatches.cs
[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))]
[HarmonyPostfix]
public static void EndGameManagerStart(EndGameManager __instance)
{
    BuildEndGameSummary(__instance);

    // NEW: Notify external plugins
    if (GameOptionsManager.Instance.CurrentGameOptions.GameMode != GameModes.HideNSeek)
    {
        var gameData = BuildGameEndData();
        GameStatsApi.NotifyGameEnd(gameData);
    }
}

private static IGameEndData BuildGameEndData()
{
    var players = new List<IPlayerData>();

    foreach (var record in EndGameData.PlayerRecords)
    {
        players.Add(new PlayerDataImpl
        {
            PlayerId = record.PlayerId,
            PlayerName = StripColorTags(record.PlayerName ?? "Unknown"),
            RoleName = ExtractLastRole(record),
            RoleHistory = ExtractRoleHistory(record.PlayerId),
            Modifiers = ExtractModifiers(record.PlayerId),
            IsWinner = record.Winner,
            // ... stats from GameHistory.PlayerStats
        });
    }

    return new GameEndDataImpl
    {
        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)),
        WinningTeam = DetermineWinningTeam(),
        Players = players
    };
}

3. Standalone Plugin (Complete)

// TownOfUsStats.dll
[BepInPlugin("com.townofus.stats", "TownOfUs Stats Exporter", "1.0.0")]
[BepInDependency("auavengers.tou.mira", "1.2.1")]
public class TownOfUsStatsPlugin : BasePlugin
{
    public override void Load()
    {
        Logger.LogInfo("=== TownOfUs Stats Exporter v1.0.0 ===");

        if (!SubscribeToGameStatsApi())
        {
            Logger.LogError("Failed to subscribe - plugin disabled");
            return;
        }

        Logger.LogInfo("Successfully initialized!");
    }

    private bool SubscribeToGameStatsApi()
    {
        try
        {
            var touAssembly = AppDomain.CurrentDomain.GetAssemblies()
                .FirstOrDefault(a => a.GetName().Name == "TownOfUs");

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

            var apiType = touAssembly.GetType("TownOfUs.Modules.GameStatsApi");
            if (apiType == null)
            {
                Logger.LogError("GameStatsApi not found - is TOU Mira up to date?");
                return false;
            }

            var onGameEndEvent = apiType.GetEvent("OnGameEnd");
            if (onGameEndEvent == null)
            {
                Logger.LogError("OnGameEnd event not found!");
                return false;
            }

            var handler = new Action<object>(OnGameEndReceived);
            var typedHandler = Delegate.CreateDelegate(
                onGameEndEvent.EventHandlerType!,
                handler.Target,
                handler.Method);

            onGameEndEvent.AddEventHandler(null, typedHandler);

            Logger.LogInfo("Subscribed to GameStatsApi.OnGameEnd");
            return true;
        }
        catch (Exception ex)
        {
            Logger.LogError($"Subscription failed: {ex}");
            return false;
        }
    }

    private void OnGameEndReceived(object gameData)
    {
        _ = Task.Run(async () =>
        {
            try
            {
                await ExportGameData(gameData);
            }
            catch (Exception ex)
            {
                Logger.LogError($"Export failed: {ex}");
            }
        });
    }

    private async Task ExportGameData(object gameData)
    {
        Logger.LogInfo("Processing game end data...");

        // Read config
        var config = await ApiConfigManager.ReadConfigAsync();
        if (!config.EnableApiExport || !config.IsValid())
        {
            Logger.LogInfo("Export disabled");
            return;
        }

        // Transform data
        var exportData = TransformGameData(gameData, config);

        // Export
        if (config.SaveLocalBackup)
        {
            await SaveLocalBackupAsync(exportData);
        }

        await SendToApiAsync(exportData, config);

        Logger.LogInfo("Export completed!");
    }

    // ... rest of implementation
}

Final Recommendation Matrix

Criteria Integrated Pure Standalone Hybrid API Weight
Performance 15%
Stability 25%
Maintenance 20%
Flexibility 15%
Type Safety 10%
User Experience 15%
TOTAL SCORE 8.3/10 3.9/10 9.1/10 100%

🏆 Winner: Hybrid API Approach

Implementacja zalecana:

  1. Dodaj minimalny API do TOU Mira (~100 linii kodu)
  2. Stwórz standalone plugin korzystający z API
  3. Release obu razem

Korzyści:

  • Najlepsza kombinacja stability + flexibility
  • Łatwe utrzymanie
  • Otwiera drzwi dla community plugins
  • Type-safe i wydajne
  • Opcjonalne dla użytkowników

End of Standalone Plugin Analysis