Initial commit

This commit is contained in:
2025-10-08 01:39:13 +02:00
commit 3a4b631c3c
22 changed files with 8540 additions and 0 deletions

80
Export/ApiClient.cs Normal file
View File

@@ -0,0 +1,80 @@
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),
};
/// <summary>
/// Sends game stats data to the API endpoint.
/// </summary>
/// <param name="data">The game stats data to send.</param>
/// <param name="endpoint">The API endpoint URL.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static async Task SendToApiAsync(GameStatsData data, string endpoint)
{
try
{
// Ensure endpoint ends with /among-data
var apiUrl = endpoint.TrimEnd('/');
if (!apiUrl.EndsWith("/among-data", StringComparison.OrdinalIgnoreCase))
{
apiUrl += "/among-data";
}
TownOfUsStatsPlugin.Logger.LogInfo($"Sending data to API: {apiUrl}");
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(apiUrl, 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}");
}
}
}

366
Export/DataTransformer.cs Normal file
View File

@@ -0,0 +1,366 @@
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" },
};
/// <summary>
/// Transforms TOU Mira data to export format.
/// </summary>
/// <param name="playerRecords">Player records from TOU Mira.</param>
/// <param name="playerStats">Player statistics from TOU Mira.</param>
/// <param name="roleHistory">Role history from TOU Mira.</param>
/// <param name="killedPlayers">Killed players list from TOU Mira.</param>
/// <param name="winningFaction">Winning faction name.</param>
/// <param name="apiToken">API authentication token.</param>
/// <param name="secret">Optional secret for authentication.</param>
/// <returns>Game statistics data ready for export.</returns>
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>());
// If roleHistory is empty, try parsing from RoleString as fallback
if (roles.Count == 0 && !string.IsNullOrEmpty(record.RoleString))
{
TownOfUsStatsPlugin.Logger.LogInfo($"RoleHistory empty for player {record.PlayerId}, parsing from RoleString: {record.RoleString}");
roles = ParseRolesFromRoleString(record.RoleString);
}
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);
// If no modifiers found via reflection, try parsing from RoleString
if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString))
{
modifiers = ParseModifiersFromRoleString(record.RoleString);
if (modifiers.Count > 0)
{
TownOfUsStatsPlugin.Logger.LogInfo($"Parsed {modifiers.Count} modifier(s) from RoleString for player {record.PlayerId}");
}
}
// Get task info
int totalTasks = 0;
int completedTasks = 0;
if (player != null && player.Data?.Tasks != null)
{
totalTasks = player.Data.Tasks.ToArray().Length;
completedTasks = player.Data.Tasks.ToArray().Count(t => t.Complete);
}
// Fix assassin kills: negative values mean incorrect guesses
// TOU Mira uses CorrectAssassinKills-- when player misguesses, resulting in -1
int correctAssassinKills = stats.CorrectAssassinKills;
int incorrectAssassinKills = stats.IncorrectAssassinKills;
if (correctAssassinKills < 0)
{
// Negative correct kills means they misguessed
incorrectAssassinKills += Math.Abs(correctAssassinKills);
correctAssassinKills = 0;
}
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 = correctAssassinKills,
IncorrectAssassinKills = 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]+>", string.Empty);
text = text.Replace("</color>", string.Empty);
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 != null)
{
// Try to get platform info - may not be available in all Among Us versions
try
{
var platformField = player.Data.GetType().GetField("Platform");
if (platformField != null)
{
var platformValue = platformField.GetValue(player.Data);
if (platformValue != null)
{
return platformValue.ToString() ?? "Unknown";
}
}
var platformProperty = player.Data.GetType().GetProperty("Platform");
if (platformProperty != null)
{
var platformValue = platformProperty.GetValue(player.Data);
if (platformValue != null)
{
return platformValue.ToString() ?? "Unknown";
}
}
}
catch
{
// Platform not available, continue
}
}
return "Unknown";
}
/// <summary>
/// Parses roles from RoleString format with color tags and separators.
/// </summary>
private static List<string> ParseRolesFromRoleString(string roleString)
{
var roles = new List<string>();
if (string.IsNullOrEmpty(roleString))
{
return roles;
}
// RoleString format: "RoleName (Modifier) (0/4) | Status | Other Info"
// We only want the role names before " > " separator
// First, split by " > " to get role history
var roleParts = roleString.Split(new[] { " > " }, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in roleParts)
{
// Strip color tags first
var cleanPart = StripColorTags(part).Trim();
// Extract just the role name before any modifiers or additional info
// Format: "RoleName (Modifier) (Tasks) | Other..."
// Remove everything after " | " (status info like "Alive", "Killed By", etc.)
var pipeIndex = cleanPart.IndexOf(" |");
if (pipeIndex > 0)
{
cleanPart = cleanPart.Substring(0, pipeIndex).Trim();
}
// Remove task info like "(0/4)" at the end
cleanPart = Regex.Replace(cleanPart, @"\s*\(\d+/\d+\)\s*$", "").Trim();
// Remove modifier info in parentheses like "(Flash)", "(Button Barry)"
// Keep only the first part before parentheses
var parenIndex = cleanPart.IndexOf('(');
if (parenIndex > 0)
{
cleanPart = cleanPart.Substring(0, parenIndex).Trim();
}
if (!string.IsNullOrEmpty(cleanPart))
{
roles.Add(cleanPart);
}
}
return roles;
}
/// <summary>
/// Parses modifiers from RoleString format.
/// Modifiers appear in parentheses after the role name.
/// Example: "Undertaker (Button Barry)" -> ["Button Barry"]
/// </summary>
private static List<string> ParseModifiersFromRoleString(string roleString)
{
var modifiers = new List<string>();
if (string.IsNullOrEmpty(roleString))
{
return modifiers;
}
// Strip color tags first
var cleanString = StripColorTags(roleString);
// Remove everything after " | " (status info)
var pipeIndex = cleanString.IndexOf(" |");
if (pipeIndex > 0)
{
cleanString = cleanString.Substring(0, pipeIndex).Trim();
}
// Remove task info like "(0/4)" at the end
cleanString = Regex.Replace(cleanString, @"\s*\(\d+/\d+\)\s*$", "").Trim();
// Now extract modifiers from parentheses
// Pattern: RoleName (Modifier1, Modifier2) or RoleName (Modifier)
var modifierPattern = @"\(([^)]+)\)";
var matches = Regex.Matches(cleanString, modifierPattern);
foreach (Match match in matches)
{
if (match.Groups.Count > 1)
{
var modifierText = match.Groups[1].Value.Trim();
// Split by comma if there are multiple modifiers
var modifierNames = modifierText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var modName in modifierNames)
{
var cleanModifier = modName.Trim();
if (!string.IsNullOrEmpty(cleanModifier))
{
modifiers.Add(cleanModifier);
}
}
}
}
return modifiers;
}
}

116
Export/StatsExporter.cs Normal file
View File

@@ -0,0 +1,116 @@
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
{
/// <summary>
/// Exports game statistics asynchronously.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
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}");
}
}
}