diff --git a/CLAUDE.md b/CLAUDE.md
index 9fb3a92..cace068 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,13 @@ The plugin **never directly references TOU Mira types**. All access is via refle
### Harmony Patching Strategy
Uses `[HarmonyPriority(Priority.Low)]` on the `AmongUsClient.OnGameEnd` patch to ensure TOU Mira's `BuildEndGameData()` runs first and populates `EndGameData.PlayerRecords` before the export begins.
+### Game Duration Tracking
+The plugin tracks actual gameplay duration (not lobby time) using `GameStartTimePatch`:
+- Patches `ShipStatus.Begin` to capture when the game round starts
+- Stores start time as `Time.time` when gameplay begins
+- Calculates duration as difference between game end time and start time
+- Resets on each game to ensure accurate per-game timing
+
### Async Export Pattern
Export runs in a fire-and-forget Task.Run() to avoid blocking the game UI. The entire export process (reflection, data transformation, API call) happens asynchronously.
@@ -63,20 +70,27 @@ If the `AmongUs` environment variable is set, the DLL auto-copies to `$(AmongUs)
1. `TownOfUsStatsPlugin.Load()` - BepInEx entry point
2. `TouMiraReflectionBridge.Initialize()` - Find TOU Mira assembly, cache reflection metadata
3. `VersionCompatibility.CheckVersion()` - Verify TOU Mira version compatibility
-4. `Harmony.PatchAll()` - Apply patches (EndGameExportPatch)
+4. `Harmony.PatchAll()` - Apply patches (EndGameExportPatch, GameStartTimePatch)
+
+### Game Start Flow
+1. **ShipStatus.Begin** fires (game round starts, gameplay begins)
+2. **GameStartTimePatch.OnShipStatusBegin** runs - records `Time.time` as game start time
### Export Flow (Game End)
1. **AmongUsClient.OnGameEnd** fires (game ends)
-2. **TOU Mira's patch** runs (High priority) - populates EndGameData.PlayerRecords
-3. **EndGameExportPatch.Postfix** runs (Low priority) - starts export
+2. **GameStartTimePatch.OnGameEndPrefix** runs - logs game duration
+3. **TOU Mira's patch** runs (High priority) - populates EndGameData.PlayerRecords
+4. **EndGameExportPatch.Postfix** runs (Low priority) - starts export
- Checks for Hide & Seek mode (skip if true)
- Fires Task.Run() with `StatsExporter.ExportGameStatsAsync()`
-4. **StatsExporter.ExportGameStatsAsync()**
+5. **StatsExporter.ExportGameStatsAsync()**
- Reads `ApiSet.ini` config
- Calls reflection bridge methods to collect data
- Transforms data via `DataTransformer.TransformToExportFormat()`
+ - Uses `GameStartTimePatch.GetGameDuration()` for accurate game time
- Saves local backup if enabled
- POSTs to API via `ApiClient.SendToApiAsync()`
+6. **GameStartTimePatch.OnGameEndPostfix** runs - clears game start time for next game
### Key Reflection Patterns
@@ -164,7 +178,7 @@ GameStatsData
│ ├── Timestamp (UTC)
│ ├── LobbyCode (e.g., "ABCDEF")
│ ├── GameMode (Normal/HideNSeek)
-│ ├── Duration (seconds)
+│ ├── Duration (seconds, actual gameplay time from intro end to game end)
│ └── Map (string)
├── Players[] (list)
│ ├── PlayerId (byte)
@@ -309,3 +323,4 @@ Symbols are extracted **before** text cleaning to preserve information:
- No compile-time type safety (all reflection is string-based)
- Breaking changes in TOU Mira require plugin updates
- Symbol encoding may vary by console/log viewer (e.g., `♥` may display as `ÔÖą` in logs)
+- to memorize
\ No newline at end of file
diff --git a/Export/DataTransformer.cs b/Export/DataTransformer.cs
index 709c7a7..2f73e33 100644
--- a/Export/DataTransformer.cs
+++ b/Export/DataTransformer.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using TownOfUsStatsExporter.Models;
+using TownOfUsStatsExporter.Patches;
using UnityEngine;
namespace TownOfUsStatsExporter.Export;
@@ -72,13 +73,16 @@ public static class DataTransformer
private static GameInfoData BuildGameInfo()
{
+ // Get actual game duration (from intro end to game end) instead of total Time.time
+ var gameDuration = GameStartTimePatch.GetGameDuration();
+
return new GameInfoData
{
GameId = Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
LobbyCode = InnerNet.GameCode.IntToGameName(AmongUsClient.Instance.GameId),
GameMode = GameOptionsManager.Instance?.CurrentGameOptions?.GameMode.ToString() ?? "Unknown",
- Duration = Time.time,
+ Duration = gameDuration,
Map = GetMapName((byte)(GameOptionsManager.Instance?.CurrentGameOptions?.MapId ?? 0)),
};
}
diff --git a/Patches/GameStartTimePatch.cs b/Patches/GameStartTimePatch.cs
new file mode 100644
index 0000000..fcb187f
--- /dev/null
+++ b/Patches/GameStartTimePatch.cs
@@ -0,0 +1,79 @@
+using HarmonyLib;
+using UnityEngine;
+
+namespace TownOfUsStatsExporter.Patches;
+
+///
+/// Tracks when the game actually starts (after intro cutscene ends).
+/// This allows calculating actual game duration instead of lobby time.
+///
+[HarmonyPatch]
+public static class GameStartTimePatch
+{
+ ///
+ /// Time when the game started (after intro cutscene), in Unity Time.time format.
+ /// Null if game hasn't started yet.
+ ///
+ public static float? GameStartTime { get; private set; }
+
+ ///
+ /// Duration of the last completed game in seconds.
+ /// Set when the game ends, persists until next game starts.
+ ///
+ public static float LastGameDuration { get; private set; }
+
+ ///
+ /// Gets the duration of the last completed game in seconds.
+ /// This value is set when the game ends and remains available for async export.
+ ///
+ public static float GetGameDuration()
+ {
+ return LastGameDuration;
+ }
+
+ ///
+ /// Patch on ShipStatus.Begin - called when the game round starts.
+ /// This is more reliable than IntroCutscene.OnDestroy as it's always called when gameplay begins.
+ ///
+ [HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.Begin))]
+ [HarmonyPostfix]
+ public static void OnShipStatusBegin()
+ {
+ GameStartTime = Time.time;
+ LastGameDuration = 0f; // Reset duration for new game
+ TownOfUsStatsPlugin.Logger.LogInfo($"Game started at Time.time = {GameStartTime.Value:F2}");
+ }
+
+ ///
+ /// Calculate and store game duration when game ends.
+ /// This runs before the export, so duration is available for async export.
+ ///
+ [HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))]
+ [HarmonyPrefix]
+ public static void OnGameEndPrefix()
+ {
+ if (GameStartTime.HasValue)
+ {
+ LastGameDuration = Time.time - GameStartTime.Value;
+ TownOfUsStatsPlugin.Logger.LogInfo($"Game ended. Duration: {LastGameDuration:F2} seconds ({LastGameDuration / 60:F2} minutes)");
+ }
+ else
+ {
+ LastGameDuration = 0f;
+ TownOfUsStatsPlugin.Logger.LogWarning("Game ended but GameStartTime was not set! Duration will be 0.");
+ }
+ }
+
+ ///
+ /// Clear game start time after game ends to prepare for next game.
+ /// LastGameDuration is preserved for async export.
+ ///
+ [HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))]
+ [HarmonyPostfix]
+ [HarmonyPriority(Priority.Last)]
+ public static void OnGameEndPostfix()
+ {
+ GameStartTime = null;
+ TownOfUsStatsPlugin.Logger.LogInfo("Game start time cleared (duration preserved for export)");
+ }
+}
diff --git a/TownOfUsStatsExporter.csproj b/TownOfUsStatsExporter.csproj
index 3467216..fd4f1f1 100644
--- a/TownOfUsStatsExporter.csproj
+++ b/TownOfUsStatsExporter.csproj
@@ -8,7 +8,7 @@
false
Town Of Us Stats Exporter - Standalone plugin for exporting game statistics
TownOfUsStatsExporter
- 1.0.3
+ 1.0.4
ToU Mira Team
diff --git a/TownOfUsStatsPlugin.cs b/TownOfUsStatsPlugin.cs
index ee3a4bb..ce59955 100644
--- a/TownOfUsStatsPlugin.cs
+++ b/TownOfUsStatsPlugin.cs
@@ -32,7 +32,7 @@ public class TownOfUsStatsPlugin : BasePlugin
///
/// Plugin version.
///
- public const string PluginVersion = "1.0.3";
+ public const string PluginVersion = "1.0.4";
///
/// Logger instance for the plugin.