diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs index 634cbeec7..dca319c98 100644 --- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -29,4 +29,72 @@ public static class ReplayManagerConstants /// Default file name for imported replays. /// public const string DefaultImportedReplayFileName = "imported_replay.rep"; -} \ No newline at end of file + + /// + /// Name of the subdirectory for auto-saved replays. + /// + public const string SavedReplaysDirectoryName = "Saved"; + + /// + /// Default replay file name that gets overwritten by the game. + /// + public const string DefaultReplayFileName = "00000000.rep"; + + /// + /// Interval in milliseconds for checking file stability. + /// + public const int FileStabilityCheckIntervalMs = 2000; + + /// + /// Number of consecutive stability checks required before considering file complete. + /// + public const int FileStabilityCheckCount = 3; + + /// + /// Minimum file size in bytes to consider a replay valid (10 KB). + /// + public const long MinimumReplayFileSizeBytes = 10 * 1024; + + /// + /// Magic bytes for replay file header validation. + /// + public const string ReplayMagicBytes = "GENREP"; + + /// + /// Date format for auto-saved replay filenames. + /// + public const string SavedReplayDateFormat = "yyyyMMdd_HHmmss"; + + /// + /// Grace period in seconds to allow stability checks to complete before stopping monitoring. + /// Calculated as: (FileStabilityCheckCount × FileStabilityCheckIntervalMs / 1000) + buffer + /// = (3 × 2000ms / 1000) + 8s buffer = 14s total + /// This accounts for the full stability check cycle plus OS flush delays on slow systems. + /// + public const int StopMonitoringGracePeriodSeconds = 14; + + /// + /// Maximum number of retry attempts when saving a replay file encounters an I/O conflict. + /// + public const int SaveRetryMaxAttempts = 3; + + /// + /// Maximum byte length for reading null-terminated strings from replay files. + /// + public const int MaxStringReadBytes = 4096; + + /// + /// Maximum number of uniqueness suffix attempts when finding a non-colliding file path. + /// + public const int MaxUniquePathAttempts = 1000; + + /// + /// Maximum number of players included in auto-save filename components. + /// + public const int MaxPlayersInSavedFileName = 4; + + /// + /// Maximum filename length in bytes (Windows/most POSIX filesystems). + /// + public const int MaxFileNameLength = 255; +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs new file mode 100644 index 000000000..8dc21bcb2 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs @@ -0,0 +1,58 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Events; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Service for monitoring replay files and automatically saving them. +/// +public interface IReplayMonitorService +{ + /// + /// Event raised when a replay file has been completed and saved. + /// + event EventHandler? ReplayCompleted; + + /// + /// Starts monitoring for a specific profile. + /// + /// The profile ID. + /// The game type. + /// A task representing the asynchronous operation. + Task StartMonitoringAsync(string profileId, GameType gameType); + + /// + /// Stops monitoring for a specific profile. + /// + /// The profile ID. + /// A task representing the asynchronous operation. + Task StopMonitoringAsync(string profileId); + + /// + /// Stops all active monitoring. + /// + /// A task representing the asynchronous operation. + Task StopAllMonitoringAsync(); + + /// + /// Stops monitoring for a specific profile, verifying session identity. + /// + /// The profile ID. + /// If provided, only stops if the active session matches this ID. + /// A task representing the asynchronous operation. + Task StopMonitoringAsync(string profileId, string? sessionId); + + /// + /// Gets a value indicating whether monitoring is active for a profile. + /// + /// The profile ID. + /// True if monitoring is active; otherwise, false. + bool IsMonitoring(string profileId); + + /// + /// Gets the session ID for an active monitoring session. + /// + /// The profile ID. + /// The session ID, or null if not monitoring. + string? GetSessionId(string profileId); +} diff --git a/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs b/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs new file mode 100644 index 000000000..b9506d804 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs @@ -0,0 +1,24 @@ +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Core.Models.Events; + +/// +/// Event arguments for replay completion. +/// +public sealed class ReplayCompletedEventArgs : EventArgs +{ + /// + /// Gets the profile ID. + /// + public required string ProfileId { get; init; } + + /// + /// Gets the saved replay file path. + /// + public required string SavedFilePath { get; init; } + + /// + /// Gets the replay metadata. + /// + public required ReplayMetadata Metadata { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs b/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs new file mode 100644 index 000000000..c8124e988 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Models.Events; + +/// +/// Event arguments for replay file completion. +/// +public sealed class ReplayFileCompletedEventArgs : EventArgs +{ + /// + /// Gets the completed file path. + /// + public required string FilePath { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs index 0b1a35291..8b591f547 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs @@ -53,6 +53,9 @@ public class CreateProfileRequest /// Gets or sets the command line arguments to pass to the game executable. public string? CommandLineArguments { get; set; } + /// Gets or sets a value indicating whether to automatically save replays for this profile. + public bool AutoSaveReplays { get; set; } + /// Gets or sets the IP address for GameSpy/Networking services. public string? GameSpyIPAddress { get; set; } diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs index 3885f73bd..d143f1f2f 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs @@ -211,6 +211,11 @@ public class GameProfile : IGameProfile /// Gets or sets a value indicating whether props are shown. public bool? VideoShowProps { get; set; } + // ===== GenHub Features ===== + + /// Gets or sets a value indicating whether to automatically save replays for this profile. + public bool AutoSaveReplays { get; set; } + // ===== TheSuperHackers Client Settings ===== /// Gets or sets a value indicating whether to archive replays automatically (TSH). diff --git a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs index eaceeaf0f..e4d3e68a4 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs @@ -90,6 +90,11 @@ public class UpdateProfileRequest /// public string? CommandLineArguments { get; set; } + /// + /// Gets or sets a value indicating whether to automatically save replays for this profile. + /// + public bool? AutoSaveReplays { get; set; } + /// /// Gets or sets the video resolution width for this profile. /// diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs new file mode 100644 index 000000000..ef74e2e73 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs @@ -0,0 +1,42 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Information about a player in a replay. +/// +public sealed class PlayerInfo +{ + /// + /// Gets the player name. + /// + public required string Name { get; init; } + + /// + /// Gets the player type (Human or AI). + /// + public required PlayerType Type { get; init; } + + /// + /// Gets the faction/side (e.g., USA, China, GLA). + /// + public string? Faction { get; init; } + + /// + /// Gets the team number. + /// + public int? Team { get; init; } + + /// + /// Gets the player color. + /// + public string? Color { get; init; } + + /// + /// Gets the AI difficulty (for AI players). + /// + public string? AiDifficulty { get; init; } + + /// + /// Gets the starting position/slot number. + /// + public int? StartPosition { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs new file mode 100644 index 000000000..274ece5c8 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Player type enumeration. +/// +public enum PlayerType +{ + /// + /// Human player. + /// + Human, + + /// + /// AI/Computer player. + /// + Computer, + + /// + /// Observer/spectator. + /// + Observer, +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs index 829018017..1f68551fc 100644 --- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs @@ -1,7 +1,9 @@ +using GenHub.Core.Models.Enums; + namespace GenHub.Core.Models.Tools.ReplayManager; /// -/// Placeholder for future replay parsing feature. +/// Metadata extracted from a replay file. /// public sealed class ReplayMetadata { @@ -11,9 +13,9 @@ public sealed class ReplayMetadata public string? MapName { get; init; } /// - /// Gets the list of players. + /// Gets the list of player information. /// - public IReadOnlyList? Players { get; init; } + public IReadOnlyList? Players { get; init; } /// /// Gets the game duration. @@ -24,4 +26,54 @@ public sealed class ReplayMetadata /// Gets the date the game was played. /// public DateTime? GameDate { get; init; } -} \ No newline at end of file + + /// + /// Gets the game type (Generals or Zero Hour). + /// + public GameType? GameType { get; init; } + + /// + /// Gets the file size in bytes. + /// + public long FileSizeBytes { get; init; } + + /// + /// Gets a value indicating whether the replay was successfully parsed. + /// + public bool IsParsed { get; init; } + + /// + /// Gets the original file path. + /// + public string? OriginalFilePath { get; init; } + + /// + /// Gets the game mode (e.g., Skirmish, Online). + /// + public string? GameMode { get; init; } + + /// + /// Gets the game version string. + /// + public string? GameVersion { get; init; } + + /// + /// Gets the build date string. + /// + public string? BuildDate { get; init; } + + /// + /// Gets the starting credits for the match. + /// + public int? StartingCredits { get; init; } + + /// + /// Gets whether fog of war was enabled. + /// + public bool? FogOfWar { get; init; } + + /// + /// Gets the game speed setting. + /// + public string? GameSpeed { get; init; } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index c3a561a9e..88e86bb9b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -6,6 +6,7 @@ using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Tools.ReplayManager; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; @@ -44,6 +45,7 @@ public class GameLauncherTests private readonly Mock _storageLocationServiceMock = new(); private readonly Mock _profileContentLinkerMock = new(); private readonly Mock _steamLauncherMock = new(); + private readonly Mock _replayMonitorServiceMock = new(); private readonly GameLauncher _gameLauncher; /// @@ -131,7 +133,8 @@ public GameLauncherTests() _gameSettingsServiceMock.Object, _profileContentLinkerMock.Object, _steamLauncherMock.Object, - _configurationProviderServiceMock.Object); + _configurationProviderServiceMock.Object, + _replayMonitorServiceMock.Object); } /// @@ -798,6 +801,76 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio Times.Once); } + /// + /// Verifies that replay monitoring is started when AutoSaveReplays is enabled. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithAutoSaveReplaysEnabled_ShouldStartMonitoring() + { + // Arrange + var profile = CreateTestProfile(); + profile.AutoSaveReplays = true; + var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace", ExecutablePath = @"C:\workspace\generals.exe" }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + _replayMonitorServiceMock.Verify( + x => x.StartMonitoringAsync(profile.Id, GameType.Generals), + Times.Once); + } + + /// + /// Verifies that replay monitoring is not started when AutoSaveReplays is disabled. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithAutoSaveReplaysDisabled_ShouldNotStartMonitoring() + { + // Arrange + var profile = CreateTestProfile(); + profile.AutoSaveReplays = false; + var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace", ExecutablePath = @"C:\workspace\generals.exe" }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success); + _replayMonitorServiceMock.Verify( + x => x.StartMonitoringAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + /// /// Creates a test with required members set. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs index 78f1ca7a9..0556d90b5 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs @@ -124,6 +124,9 @@ public void AllViewModels_Registered() services.AddUserDataServices(); services.AddLaunchingServices(); services.AddToolsServices(); + services.AddUploadThingServices(); + services.AddReplayManagerServices(); + services.AddMapManager(); services.AddSharedViewModelModule(); // Register IManifestIdService diff --git a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs index 6d9145e38..367ec0bb3 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs @@ -130,6 +130,7 @@ public async Task> CreateProfileAsync(Create IconPath = request.IconPath, CoverPath = request.CoverPath, CommandLineArguments = request.CommandLineArguments ?? string.Empty, + AutoSaveReplays = request.AutoSaveReplays, GameSpyIPAddress = request.GameSpyIPAddress, }; @@ -213,6 +214,7 @@ public async Task> UpdateProfileAsync(string profile.GameInstallationId = request.GameInstallationId ?? profile.GameInstallationId; profile.ToolContentId = request.ToolContentId ?? profile.ToolContentId; profile.CommandLineArguments = request.CommandLineArguments ?? profile.CommandLineArguments; + profile.AutoSaveReplays = request.AutoSaveReplays ?? profile.AutoSaveReplays; // Detect if content changed and invalidate workspace if needed bool contentChanged = false; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index 3e8738511..612d2ab49 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -368,6 +368,7 @@ private async Task SaveAsync() WorkspaceStrategy = SelectedWorkspaceStrategy, EnabledContentIds = enabledContentIds, CommandLineArguments = CommandLineArguments, + AutoSaveReplays = AutoSaveReplays, IconPath = IconPath, CoverPath = CoverPath, ThemeColor = ColorValue, @@ -413,6 +414,7 @@ private async Task SaveAsync() : null, EnabledContentIds = enabledContentIds, CommandLineArguments = CommandLineArguments, + AutoSaveReplays = AutoSaveReplays, IconPath = IconPath, CoverPath = CoverPath, }; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index 335b171c4..e6fe67b70 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -41,6 +41,8 @@ public virtual async Task InitializeForNewProfileAsync() ColorValue = "#1976D2"; SelectedWorkspaceStrategy = GetDefaultWorkspaceStrategy(); SelectedContentType = ContentType.GameClient; + AutoSaveReplays = false; // Reset to prevent stale state leakage + IsToolProfile = false; EnabledContent.Clear(); @@ -158,6 +160,8 @@ public virtual async Task InitializeForProfileAsync(string profileId) SelectedWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); _originalWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); CommandLineArguments = profile.CommandLineArguments ?? string.Empty; + AutoSaveReplays = profile.AutoSaveReplays; + IsToolProfile = profile.IsToolProfile; LoadAvailableIconsAndCovers(profile.GameClient?.GameType.ToString() ?? "ZeroHour"); GameTypeFilter = profile.GameClient?.GameType ?? Core.Models.Enums.GameType.ZeroHour; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs index aba8bbfe4..e89699f9d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs @@ -114,6 +114,30 @@ public ContentType SelectedContentType [ObservableProperty] private string _commandLineArguments = string.Empty; + [ObservableProperty] + private bool _autoSaveReplays; + + [ObservableProperty] + private ObservableCollection _availableCovers = []; + + [ObservableProperty] + private ProfileInfoItem? _selectedCover; + + [ObservableProperty] + private ObservableCollection _availableGameClients = []; + + [ObservableProperty] + private ProfileInfoItem? _selectedClient; + + [ObservableProperty] + private string _formattedSize = string.Empty; + + [ObservableProperty] + private string _buildDate = string.Empty; + + [ObservableProperty] + private string _sourceType = string.Empty; + [ObservableProperty] private string _shortcutPath = string.Empty; @@ -197,4 +221,7 @@ public ContentType SelectedContentType [ObservableProperty] private GameType _selectedLocalGameType = Core.Models.Enums.GameType.ZeroHour; + + [ObservableProperty] + private bool _isToolProfile; } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml index ecb661d50..2785cfb26 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml @@ -198,6 +198,12 @@ + + + + + + diff --git a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs index 338ac29f5..b446e33ab 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs @@ -11,6 +11,7 @@ using GenHub.Features.GameProfiles.ViewModels; using GenHub.Features.Info.Services; using GenHub.Features.Tools.MapManager.ViewModels; +using GenHub.Features.Tools.ReplayManager.Services; using GenHub.Features.Tools.ReplayManager.ViewModels; using GenHub.Infrastructure.Imaging; using Microsoft.Extensions.Logging; @@ -224,12 +225,15 @@ public static ReplayManagerViewModel CreateDemoReplayManager(INotificationServic // Use the provided notification service or fall back to mock var mockNotify = notificationService ?? new MockNotificationService(); var mockLogger = new MockLogger(); + var mockParserLogger = new MockLogger(); + var mockParser = new ReplayParserService(mockParserLogger); var vm = new ReplayManagerViewModel( mockDir, mockImport, mockExport, mockHistory, + mockParser, mockNotify, mockLogger); @@ -246,6 +250,7 @@ public static ReplayManagerViewModel CreateDemoReplayManager(INotificationServic new MockReplayImportService(), new MockReplayExportService(), new MockUploadHistoryService(), + new ReplayParserService(new MockLogger()), new MockNotificationService(), new MockLogger()); } diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index d0a7e017d..81b16fbd1 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -18,6 +18,7 @@ using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Tools.ReplayManager; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.Enums; @@ -48,7 +49,8 @@ public class GameLauncher( IGameSettingsService gameSettingsService, IProfileContentLinker profileContentLinker, ISteamLauncher steamLauncher, - IConfigurationProviderService configurationProvider) : IGameLauncher + IConfigurationProviderService configurationProvider, + IReplayMonitorService replayMonitorService) : IGameLauncher { private static readonly ConcurrentDictionary _profileLaunchLocks = new(); private static readonly SearchValues InvalidArgChars = SearchValues.Create(";|&\n\r`$%"); @@ -1029,6 +1031,20 @@ private async Task> LaunchProfileAsync(Gam logger.LogDebug("[GameLauncher] Updating launch registry with real process info"); await launchRegistry.RegisterLaunchAsync(launchInfo); + // Start replay monitoring if enabled for this profile + if (profile.AutoSaveReplays && !profile.IsToolProfile && profile.GameClient != null) + { + try + { + await replayMonitorService.StartMonitoringAsync(profile.Id, profile.GameClient.GameType); + logger.LogInformation("[GameLauncher] Started replay monitoring for profile {ProfileId}", profile.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "[GameLauncher] Failed to start replay monitoring for profile {ProfileId}", profile.Id); + } + } + // Report completion progress?.Report(new LaunchProgress { Phase = LaunchPhase.Running, PercentComplete = 100 }); logger.LogInformation("[GameLauncher] === Launch completed successfully for profile {ProfileId} ===", profile.Id); diff --git a/GenHub/GenHub/Features/Launching/LaunchRegistry.cs b/GenHub/GenHub/Features/Launching/LaunchRegistry.cs index 4ab36c1aa..95363c95d 100644 --- a/GenHub/GenHub/Features/Launching/LaunchRegistry.cs +++ b/GenHub/GenHub/Features/Launching/LaunchRegistry.cs @@ -4,8 +4,10 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Tools.ReplayManager; using GenHub.Core.Interfaces.Workspace; using GenHub.Core.Models.GameProfile; using Microsoft.Extensions.Logging; @@ -21,6 +23,7 @@ public class LaunchRegistry : ILaunchRegistry private readonly ILogger _logger; private readonly IWorkspaceManager? _workspaceManager; private readonly IGameProcessManager? _processManager; + private readonly IReplayMonitorService? _replayMonitorService; private readonly ConcurrentDictionary _activeLaunches = new(); /// @@ -29,14 +32,17 @@ public class LaunchRegistry : ILaunchRegistry /// The logger instance. /// Optional workspace manager for cleanup. /// Optional process manager for tracking game processes. + /// Optional replay monitor service for auto-save cleanup. public LaunchRegistry( ILogger logger, IWorkspaceManager? workspaceManager = null, - IGameProcessManager? processManager = null) + IGameProcessManager? processManager = null, + IReplayMonitorService? replayMonitorService = null) { _logger = logger; _workspaceManager = workspaceManager; _processManager = processManager; + _replayMonitorService = replayMonitorService; if (_processManager != null) { @@ -134,6 +140,33 @@ private void OnProcessExited(object? sender, Core.Models.Events.GameProcessExite } launch.ProcessInfo.IsRunning = false; + + // Stop replay monitoring for this profile after a grace period + // The ReplayMonitor needs ~6s (3 checks × 2s) after the file stops changing + // Add buffer time to account for OS flush delays and slow systems + // Grace period = stability checks (6s) + buffer (8s) = 14s total + // Capture session ID to avoid canceling a newer session if profile is relaunched + if (_replayMonitorService != null && _replayMonitorService.IsMonitoring(launch.ProfileId)) + { + var sessionId = _replayMonitorService.GetSessionId(launch.ProfileId); + if (sessionId != null) + { + _ = Task.Run(async () => + { + try + { + // Extended grace period to allow for late file writes and full stability checks + await Task.Delay(TimeSpan.FromSeconds(ReplayManagerConstants.StopMonitoringGracePeriodSeconds)); + await _replayMonitorService.StopMonitoringAsync(launch.ProfileId, sessionId); + _logger.LogInformation("[LaunchRegistry] Stopped replay monitoring for profile {ProfileId}", launch.ProfileId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[LaunchRegistry] Error stopping replay monitoring for profile {ProfileId}", launch.ProfileId); + } + }); + } + } } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs new file mode 100644 index 000000000..ff95679d2 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GenHub.Features.Tools.ReplayManager.Converters; + +/// +/// Converts color name string to a color brush. +/// +public class ColorNameToBrushConverter : IValueConverter +{ + /// + /// Converts a color name string to a SolidColorBrush. + /// + /// The color name string to convert. + /// The target type (not used). + /// Optional parameter (not used). + /// Culture information (not used). + /// A SolidColorBrush representing the named color. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string colorName) + { + return colorName.ToLowerInvariant() switch + { + "orange" => new SolidColorBrush(Color.Parse("#FF9800")), + "pink" => new SolidColorBrush(Color.Parse("#E91E63")), + "blue" => new SolidColorBrush(Color.Parse("#2196F3")), + "green" => new SolidColorBrush(Color.Parse("#4CAF50")), + "red" => new SolidColorBrush(Color.Parse("#F44336")), + "yellow" => new SolidColorBrush(Color.Parse("#FFEB3B")), + "purple" => new SolidColorBrush(Color.Parse("#9C27B0")), + "teal" => new SolidColorBrush(Color.Parse("#009688")), + _ => new SolidColorBrush(Colors.Gray), + }; + } + + return new SolidColorBrush(Colors.Gray); + } + + /// + /// Converts back from brush to color name (not implemented). + /// + /// The value to convert back. + /// The target type. + /// Optional parameter. + /// Culture information. + /// Not implemented. + /// This conversion is not supported. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs new file mode 100644 index 000000000..7789b2970 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Features.Tools.ReplayManager.Converters; + +/// +/// Converts PlayerType enum to a color brush. +/// +public class PlayerTypeToColorConverter : IValueConverter +{ + /// + /// Converts a PlayerType value to a SolidColorBrush. + /// + /// The PlayerType value to convert. + /// The target type (not used). + /// Optional parameter (not used). + /// Culture information (not used). + /// A SolidColorBrush representing the player type color. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is PlayerType playerType) + { + return playerType switch + { + PlayerType.Human => new SolidColorBrush(Color.Parse("#2196F3")), + PlayerType.Computer => new SolidColorBrush(Color.Parse("#FF9800")), + PlayerType.Observer => new SolidColorBrush(Color.Parse("#9E9E9E")), + _ => new SolidColorBrush(Colors.Gray), + }; + } + + return new SolidColorBrush(Colors.Gray); + } + + /// + /// Converts back from brush to PlayerType (not implemented). + /// + /// The value to convert back. + /// The target type. + /// Optional parameter. + /// Culture information. + /// Not implemented. + /// This conversion is not supported. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs new file mode 100644 index 000000000..962d30297 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Features.Tools.ReplayManager.Converters; + +/// +/// Converts PlayerType enum to an icon/emoji. +/// +public class PlayerTypeToIconConverter : IValueConverter +{ + /// + /// Converts a PlayerType value to an icon string. + /// + /// The PlayerType value to convert. + /// The target type (not used). + /// Optional parameter (not used). + /// Culture information (not used). + /// An icon string representing the player type. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is PlayerType playerType) + { + return playerType switch + { + PlayerType.Human => "👤", + PlayerType.Computer => "🤖", + PlayerType.Observer => "👁️", + _ => "?", + }; + } + + return "?"; + } + + /// + /// Converts back from icon to PlayerType (not implemented). + /// + /// The value to convert back. + /// The target type. + /// Optional parameter. + /// Culture information. + /// Not implemented. + /// This conversion is not supported. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs new file mode 100644 index 000000000..8231be19b --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs @@ -0,0 +1,297 @@ +using System; +using System.IO; +using System.Threading; +using GenHub.Core.Constants; +using GenHub.Core.Models.Events; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Monitors a replay file for completion using FileSystemWatcher and stability checks. +/// +/// The logger instance. +public sealed class ReplayMonitor(ILogger logger) : IDisposable +{ + private readonly object _lock = new(); + private FileSystemWatcher? _watcher; + private Timer? _stabilityTimer; + private long _lastFileSize; + private int _stabilityCheckCount; + private bool _isDisposed; + + /// + /// Event raised when the replay file is complete and stable. + /// + public event EventHandler? FileCompleted; + + /// + /// Gets the file path being monitored. + /// + public string? FilePath { get; private set; } + + /// + /// Gets a value indicating whether monitoring is active. + /// + public bool IsMonitoring { get; private set; } + + /// + /// Starts monitoring a replay file. + /// + /// The file path to monitor. + public void StartMonitoring(string filePath) + { + lock (_lock) + { + if (_isDisposed) + { + logger.LogWarning("Cannot start monitoring on a disposed ReplayMonitor"); + return; + } + + if (IsMonitoring) + { + logger.LogWarning("Already monitoring a file: {FilePath}", FilePath); + return; + } + + FilePath = filePath; + _lastFileSize = 0; + _stabilityCheckCount = 0; + + var directory = Path.GetDirectoryName(filePath); + var fileName = Path.GetFileName(filePath); + + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + { + logger.LogError("Invalid file path: {FilePath}", filePath); + return; + } + + if (!Directory.Exists(directory)) + { + logger.LogWarning("Directory does not exist, creating: {Directory}", directory); + Directory.CreateDirectory(directory); + } + + _watcher = new FileSystemWatcher(directory, fileName) + { + NotifyFilter = NotifyFilters.Size, + }; + + _watcher.Changed += OnFileChanged; + _watcher.Error += OnWatcherError; + + try + { + _watcher.EnableRaisingEvents = true; + IsMonitoring = true; + logger.LogInformation("Started monitoring replay file: {FilePath}", filePath); + } + catch + { + // Clean up watcher if EnableRaisingEvents fails + _watcher.Changed -= OnFileChanged; + _watcher.Error -= OnWatcherError; + _watcher.Dispose(); + _watcher = null; + throw; + } + + // Don't start stability checks on pre-existing files + // Wait for FileSystemWatcher to detect actual changes from the game + } + } + + /// + /// Stops monitoring. + /// + public void StopMonitoring() + { + lock (_lock) + { + // Always clean up the watcher regardless of IsMonitoring state + if (_watcher != null) + { + _watcher.Changed -= OnFileChanged; + _watcher.Error -= OnWatcherError; + _watcher.Dispose(); + _watcher = null; + } + + if (!IsMonitoring) + { + return; + } + + IsMonitoring = false; + + _stabilityTimer?.Dispose(); + _stabilityTimer = null; + + logger.LogInformation("Stopped monitoring replay file: {FilePath}", FilePath); + + FilePath = null; + _lastFileSize = 0; + _stabilityCheckCount = 0; + } + } + + /// + public void Dispose() + { + lock (_lock) + { + if (_isDisposed) + { + return; + } + + StopMonitoring(); + _isDisposed = true; + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + lock (_lock) + { + if (!IsMonitoring || !string.Equals(FilePath, e.FullPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + var fileInfo = new FileInfo(e.FullPath); + if (!fileInfo.Exists) + { + return; + } + + var currentSize = fileInfo.Length; + + if (currentSize != _lastFileSize) + { + _lastFileSize = currentSize; + _stabilityCheckCount = 0; + StartStabilityCheck(); + + logger.LogDebug("Replay file changed: {FilePath} ({Size} bytes)", e.FullPath, currentSize); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling file change: {FilePath}", e.FullPath); + } + } + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + lock (_lock) + { + logger.LogError(e.GetException(), "FileSystemWatcher error for: {FilePath}", FilePath); + + if (!IsMonitoring || _watcher == null) + { + return; + } + + // Attempt to restart the watcher to recover from buffer overflow or similar errors + try + { + _watcher.EnableRaisingEvents = false; + _watcher.EnableRaisingEvents = true; + logger.LogInformation("Restarted FileSystemWatcher after error for: {FilePath}", FilePath); + } + catch (Exception restartEx) + { + logger.LogError(restartEx, "Failed to restart FileSystemWatcher for: {FilePath}", FilePath); + + // Transition to a consistent stopped state so the grace-period cleanup or a caller retry can take over + StopMonitoring(); + } + } + } + + private void StartStabilityCheck() + { + _stabilityTimer?.Dispose(); + + _stabilityTimer = new Timer( + CheckFileStability, + null, + ReplayManagerConstants.FileStabilityCheckIntervalMs, + Timeout.Infinite); + } + + private void CheckFileStability(object? state) + { + string? completedFilePath = null; + + lock (_lock) + { + if (!IsMonitoring || string.IsNullOrEmpty(FilePath)) + { + return; + } + + try + { + var fileInfo = new FileInfo(FilePath); + if (!fileInfo.Exists) + { + return; + } + + var currentSize = fileInfo.Length; + + if (currentSize == _lastFileSize && currentSize >= ReplayManagerConstants.MinimumReplayFileSizeBytes) + { + _stabilityCheckCount++; + + logger.LogDebug( + "Replay file stable check {Count}/{Required}: {FilePath} ({Size} bytes)", + _stabilityCheckCount, + ReplayManagerConstants.FileStabilityCheckCount, + FilePath, + currentSize); + + if (_stabilityCheckCount >= ReplayManagerConstants.FileStabilityCheckCount) + { + logger.LogInformation("Replay file completed: {FilePath} ({Size} bytes)", FilePath, _lastFileSize); + completedFilePath = FilePath; + StopMonitoring(); + } + else + { + _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite); + } + } + else + { + if (currentSize != _lastFileSize) + { + _lastFileSize = currentSize; + _stabilityCheckCount = 0; + _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite); + } + + // If size is unchanged but below minimum, let the FileSystemWatcher drive the next check + // instead of spinning the timer indefinitely. + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking file stability: {FilePath}", FilePath); + } + } + + // Raise event outside _lock to avoid blocking the monitor while handlers execute + if (completedFilePath != null) + { + FileCompleted?.Invoke(this, new ReplayFileCompletedEventArgs { FilePath = completedFilePath }); + } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs new file mode 100644 index 000000000..e811a3c66 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Events; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Service for orchestrating replay monitoring and automatic saving. +/// +/// The directory service. +/// The save service. +/// The logger factory. +/// The logger instance. +public sealed class ReplayMonitoringService( + IReplayDirectoryService directoryService, + ReplaySaveService saveService, + ILoggerFactory loggerFactory, + ILogger logger) : IReplayMonitorService, IDisposable +{ + private readonly ConcurrentDictionary _activeSessions = new(); + private volatile bool _isDisposed; + + /// + public event EventHandler? ReplayCompleted; + + /// + public Task StartMonitoringAsync(string profileId, GameType gameType) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(profileId); + + // Stop existing monitoring for this profile + StopMonitoringInternal(profileId); + + var replayDirectory = directoryService.GetReplayDirectory(gameType); + var replayFilePath = Path.Combine(replayDirectory, ReplayManagerConstants.DefaultReplayFileName); + + var monitor = new ReplayMonitor(loggerFactory.CreateLogger()); + var sessionId = Guid.NewGuid().ToString(); + + monitor.FileCompleted += async (sender, args) => + { + try + { + await OnReplayFileCompletedAsync(profileId, gameType, sessionId, args); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception in FileCompleted event handler for profile: {ProfileId}", profileId); + } + }; + + var session = new MonitoringSession + { + SessionId = sessionId, + ProfileId = profileId, + GameType = gameType, + Monitor = monitor, + ReplayFilePath = replayFilePath, + }; + + if (!_activeSessions.TryAdd(profileId, session)) + { + monitor.Dispose(); + logger.LogWarning("Failed to add monitoring session for profile: {ProfileId}", profileId); + return Task.CompletedTask; + } + + try + { + monitor.StartMonitoring(replayFilePath); + } + catch + { + _activeSessions.TryRemove(new KeyValuePair(profileId, session)); + monitor.Dispose(); + throw; + } + + logger.LogInformation( + "Started replay monitoring for profile {ProfileId} ({GameType}) session {SessionId}: {FilePath}", + profileId, + gameType, + sessionId, + replayFilePath); + + return Task.CompletedTask; + } + + /// + public Task StopMonitoringAsync(string profileId) + { + return StopMonitoringAsync(profileId, sessionId: null); + } + + /// + /// Stops monitoring for a specific profile, optionally verifying session identity. + /// + /// The profile ID. + /// If provided, only stops if the active session matches this ID. + /// A task representing the asynchronous operation. + public Task StopMonitoringAsync(string profileId, string? sessionId) + { + if (string.IsNullOrWhiteSpace(profileId)) + { + return Task.CompletedTask; + } + + if (sessionId != null) + { + StopMonitoringInternal(profileId, sessionId); + } + else + { + StopMonitoringInternal(profileId); + } + + return Task.CompletedTask; + } + + /// + public Task StopAllMonitoringAsync() + { + foreach (var profileId in _activeSessions.Keys.ToArray()) + { + StopMonitoringInternal(profileId); + } + + logger.LogInformation("Stopped all replay monitoring"); + return Task.CompletedTask; + } + + /// + public bool IsMonitoring(string profileId) + { + return !string.IsNullOrWhiteSpace(profileId) && _activeSessions.ContainsKey(profileId); + } + + /// + /// Gets the session ID for an active monitoring session. + /// + /// The profile ID. + /// The session ID, or null if not monitoring. + public string? GetSessionId(string profileId) + { + return _activeSessions.TryGetValue(profileId, out var session) ? session.SessionId : null; + } + + /// + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + foreach (var session in _activeSessions.Values.ToArray()) + { + try + { + session.Monitor.Dispose(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error disposing monitor for session: {SessionId}", session.SessionId); + } + } + + _activeSessions.Clear(); + } + + private void StopMonitoringInternal(string profileId) + { + if (_activeSessions.TryRemove(profileId, out var session)) + { + session.Monitor.Dispose(); + logger.LogInformation("Stopped replay monitoring for profile: {ProfileId} session: {SessionId}", profileId, session.SessionId); + } + } + + private void StopMonitoringInternal(string profileId, string expectedSessionId) + { + // Optimistic path: only attempt removal if the session matches + if (!_activeSessions.TryGetValue(profileId, out var existingSession) || + existingSession?.SessionId != expectedSessionId) + { + logger.LogInformation( + "Skipping stop for profile {ProfileId}: session {ExpectedSession} not found or replaced", + profileId, + expectedSessionId); + return; + } + + // TryRemove with KeyValuePair is atomic - only removes if both key and value match + // This prevents removing a newer session inserted by concurrent StartMonitoringAsync + if (_activeSessions.TryRemove(new KeyValuePair(profileId, existingSession))) + { + existingSession.Monitor.Dispose(); + logger.LogInformation( + "Stopped replay monitoring for profile: {ProfileId} session: {SessionId}", + profileId, + existingSession.SessionId); + } + else + { + // Session was replaced between TryGetValue and TryRemove + logger.LogInformation( + "Skipping stop for profile {ProfileId}: session {ExpectedSession} was replaced during removal", + profileId, + expectedSessionId); + } + } + + private async Task OnReplayFileCompletedAsync(string profileId, GameType gameType, string sessionId, ReplayFileCompletedEventArgs args) + { + try + { + logger.LogInformation( + "Replay file completed for profile {ProfileId}: {FilePath}", + profileId, + args.FilePath); + + var (savedFilePath, metadata) = await saveService.SaveReplayAsync(args.FilePath, gameType); + + if (string.IsNullOrEmpty(savedFilePath) || metadata == null) + { + logger.LogWarning("Failed to save replay for profile: {ProfileId}", profileId); + return; + } + + ReplayCompleted?.Invoke(this, new ReplayCompletedEventArgs + { + ProfileId = profileId, + SavedFilePath = savedFilePath, + Metadata = metadata, + }); + + logger.LogInformation( + "Successfully saved replay for profile {ProfileId}: {SavedFilePath}", + profileId, + savedFilePath); + + // Only stop monitoring after successful save + StopMonitoringInternal(profileId, sessionId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling replay completion for profile: {ProfileId}", profileId); + } + } + + private sealed class MonitoringSession + { + public required string SessionId { get; init; } + + public required string ProfileId { get; init; } + + public required GameType GameType { get; init; } + + public required ReplayMonitor Monitor { get; init; } + + public required string ReplayFilePath { get; init; } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs new file mode 100644 index 000000000..2d9592758 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Service for parsing replay file headers and extracting metadata. +/// +/// The logger instance. +public sealed class ReplayParserService(ILogger logger) +{ + // Faction mappings for C&C Generals/Zero Hour + private static readonly Dictionary FactionMap = new() + { + { "FactionAmerica", "USA" }, + { "FactionChina", "China" }, + { "FactionGLA", "GLA" }, + { "AmericaAirForceGeneral", "USA Air Force" }, + { "AmericaLaserGeneral", "USA Laser" }, + { "AmericaSuperweaponGeneral", "USA Superweapon" }, + { "ChinaInfantryGeneral", "China Infantry" }, + { "ChinaNukeGeneral", "China Nuke" }, + { "ChinaTankGeneral", "China Tank" }, + { "GLADemolitionGeneral", "GLA Demolition" }, + { "GLAStealthGeneral", "GLA Stealth" }, + { "GLAToxinGeneral", "GLA Toxin" }, + }; + + private static readonly Dictionary ColorMap = new() + { + { "0", "Orange" }, + { "1", "Pink" }, + { "2", "Blue" }, + { "3", "Green" }, + { "4", "Red" }, + { "5", "Yellow" }, + { "6", "Purple" }, + { "7", "Teal" }, + }; + + /// + /// Parses a replay file and extracts metadata. + /// + /// The path to the replay file. + /// The game type. + /// The extracted metadata. + public Task ParseReplayAsync(string filePath, GameType gameType) + { + // Offload synchronous I/O to thread pool to avoid blocking + return Task.Run(() => ParseReplayCore(filePath, gameType)); + } + + private static ReplayMetadata CreateEmptyMetadata(string filePath, long fileSize, GameType gameType) + { + DateTime gameDate; + try + { + gameDate = File.Exists(filePath) ? File.GetLastWriteTime(filePath) : DateTime.Now; + } + catch + { + gameDate = DateTime.Now; + } + + return new ReplayMetadata + { + GameDate = gameDate, + GameType = gameType, + FileSizeBytes = fileSize, + IsParsed = false, + OriginalFilePath = filePath, + }; + } + + private static string ReadNullTerminatedString(BinaryReader reader, Encoding encoding) + { + var bytes = new List(ReplayManagerConstants.MaxStringReadBytes); + + // Use CodePage comparison for robust encoding detection + var charSize = encoding.CodePage == Encoding.Unicode.CodePage ? 2 : 1; + + while (bytes.Count + charSize <= ReplayManagerConstants.MaxStringReadBytes) + { + var charBytes = reader.ReadBytes(charSize); + if (charBytes.Length < charSize || IsNullTerminator(charBytes, charSize)) + { + break; + } + + bytes.AddRange(charBytes); + } + + // If we exited because of the byte limit (not a null terminator), drain to the null terminator + // so subsequent reads start at the correct position. + if (bytes.Count + charSize > ReplayManagerConstants.MaxStringReadBytes) + { + byte[] drain; + do + { + drain = reader.ReadBytes(charSize); + } + while (drain.Length == charSize && !IsNullTerminator(drain, charSize)); + } + + return bytes.Count > 0 ? encoding.GetString(bytes.ToArray()) : string.Empty; + } + + private static bool IsNullTerminator(byte[] bytes, int charSize) + { + if (charSize == 2) + { + return bytes.Length >= 2 && bytes[0] == 0 && bytes[1] == 0; + } + + return bytes.Length >= 1 && bytes[0] == 0; + } + + private static ParsedMatchData ParseMatchData(string matchData) + { + var result = new ParsedMatchData(); + + if (string.IsNullOrWhiteSpace(matchData)) + { + return result; + } + + var entries = matchData.Split(';', StringSplitOptions.RemoveEmptyEntries); + + foreach (var entry in entries) + { + var separatorIndex = entry.IndexOf('='); + if (separatorIndex < 0) + { + continue; + } + + var key = entry[..separatorIndex].Trim(); + var value = entry[(separatorIndex + 1)..].Trim(); + + if (string.IsNullOrEmpty(value)) + { + continue; + } + + switch (key) + { + case "M": + result.MapName = value; + break; + + case "MC": + result.StartingCredits = int.TryParse(value, out var credits) ? credits : null; + break; + + case "MS": + result.GameSpeed = value switch + { + "0" => "Slow", + "1" => "Normal", + "2" => "Fast", + _ => value, + }; + break; + + case "SD": + result.GameMode = value switch + { + "0" => "Skirmish", + "1" => "Online", + _ => value, + }; + break; + + case "FOG": + result.FogOfWar = value == "1"; + break; + + case "S": + // S field format: slot records separated by colons + // Each slot: playerSpec,faction,team,color,... + // playerSpec: H for human, C[E|M|H|B] for AI, X for empty + result.Players = ParsePlayerSlots(value); + break; + } + } + + return result; + } + + private static List? ParsePlayerSlots(string slotsData) + { + var players = new List(); + + // Split by colon to get individual slot records + var slots = slotsData.Split(':', StringSplitOptions.RemoveEmptyEntries); + + for (int slotIndex = 0; slotIndex < slots.Length; slotIndex++) + { + var slot = slots[slotIndex].Trim(); + if (string.IsNullOrEmpty(slot) || slot == "X") + { + continue; // Empty slot + } + + // Split slot by comma to get fields - preserve positions for sparse records + var fields = slot.Split(','); + if (fields.Length == 0) + { + continue; + } + + var playerSpec = fields[0].Trim(); + + // Handle empty fields explicitly to preserve positional semantics + string? faction = fields.Length > 1 && !string.IsNullOrWhiteSpace(fields[1]) ? fields[1].Trim() : null; + int? team = fields.Length > 2 && int.TryParse(fields[2].Trim(), out var t) ? t : null; + string? colorCode = fields.Length > 3 && !string.IsNullOrWhiteSpace(fields[3]) ? fields[3].Trim() : null; + + PlayerInfo? playerInfo = null; + + if (playerSpec.StartsWith('H') && playerSpec.Length > 1) + { + // Human player: name follows 'H' prefix + var playerName = playerSpec[1..]; + playerInfo = new PlayerInfo + { + Name = playerName, + Type = PlayerType.Human, + Faction = faction != null && FactionMap.TryGetValue(faction, out var factionName) ? factionName : faction, + Team = team, + Color = colorCode != null && ColorMap.TryGetValue(colorCode, out var color) ? color : colorCode, + StartPosition = slotIndex + 1, + }; + } + else if (playerSpec.StartsWith('C') && playerSpec.Length > 1) + { + // Computer player: CE=Easy, CM=Medium, CH=Hard, CB=Brutal + var difficulty = playerSpec[1] switch + { + 'E' => "Easy", + 'M' => "Medium", + 'H' => "Hard", + 'B' => "Brutal", + _ => "Unknown", + }; + + playerInfo = new PlayerInfo + { + Name = $"AI ({difficulty})", + Type = PlayerType.Computer, + AiDifficulty = difficulty, + Faction = faction != null && FactionMap.TryGetValue(faction, out var factionName) ? factionName : faction, + Team = team, + Color = colorCode != null && ColorMap.TryGetValue(colorCode, out var color) ? color : colorCode, + StartPosition = slotIndex + 1, + }; + } + else if (playerSpec.StartsWith('O')) + { + // Observer + var observerName = playerSpec.Length > 1 ? playerSpec[1..] : "Observer"; + playerInfo = new PlayerInfo + { + Name = observerName, + Type = PlayerType.Observer, + StartPosition = slotIndex + 1, + }; + } + + if (playerInfo != null) + { + players.Add(playerInfo); + } + } + + return players.Count > 0 ? players : null; + } + + private ReplayMetadata ParseReplayCore(string filePath, GameType gameType) + { + try + { + var fileInfo = new FileInfo(filePath); + if (!fileInfo.Exists) + { + logger.LogWarning("Replay file not found: {FilePath}", filePath); + return CreateEmptyMetadata(filePath, 0, gameType); + } + + if (fileInfo.Length < ReplayManagerConstants.MinimumReplayFileSizeBytes) + { + logger.LogWarning("Replay file too small: {FilePath} ({Size} bytes)", filePath, fileInfo.Length); + return CreateEmptyMetadata(filePath, fileInfo.Length, gameType); + } + + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new BinaryReader(stream); + + var magic = reader.ReadBytes(6); + var magicString = Encoding.ASCII.GetString(magic); + if (magicString != ReplayManagerConstants.ReplayMagicBytes) + { + logger.LogWarning("Invalid replay file format: {FilePath} (magic: {Magic})", filePath, magicString); + return CreateEmptyMetadata(filePath, fileInfo.Length, gameType); + } + + // Read timestamps per GENREP spec (only 2 UInt32 values) + var beginTimestamp = reader.ReadUInt32(); + var endTimestamp = reader.ReadUInt32(); + + // Skip unknown1[12] bytes (Generals/ZH specific) + reader.ReadBytes(12); + + // Read null-terminated UTF-16 replay filename (e.g. "Last Replay") + ReadNullTerminatedString(reader, Encoding.Unicode); + + // Skip date_time[8] (8 x uint16 = 16 bytes: year, month, dayOfWeek, day, hour, minute, second, millisecond) + reader.ReadBytes(16); + + // Read version and build date strings + var gameVersion = ReadNullTerminatedString(reader, Encoding.Unicode); + var buildDate = ReadNullTerminatedString(reader, Encoding.Unicode); + + // Skip version_minor (2 bytes) + version_major (2 bytes) + magic_hash[8] + reader.ReadBytes(12); + + // Read match data — null-terminated ASCII metadata string (key=value pairs separated by ;) + var matchData = ReadNullTerminatedString(reader, Encoding.ASCII); + + logger.LogDebug("Raw match data: {MatchData}", matchData); + + var parsedData = ParseMatchData(matchData); + + logger.LogInformation( + "Parsed: Map={Map}, Players={PlayerCount}, Mode={Mode}, Credits={Credits}", + parsedData.MapName ?? "null", + parsedData.Players?.Count ?? 0, + parsedData.GameMode ?? "null", + parsedData.StartingCredits); + + var gameDate = beginTimestamp > 0 + ? DateTimeOffset.FromUnixTimeSeconds(beginTimestamp).LocalDateTime + : fileInfo.LastWriteTime; + + var duration = endTimestamp > beginTimestamp && beginTimestamp > 0 + ? TimeSpan.FromSeconds(endTimestamp - beginTimestamp) + : (TimeSpan?)null; + + logger.LogInformation( + "Successfully parsed replay: {FilePath} (Map: {Map}, Players: {PlayerCount}, Duration: {Duration})", + filePath, + parsedData.MapName ?? "Unknown", + parsedData.Players?.Count ?? 0, + duration); + + return new ReplayMetadata + { + MapName = parsedData.MapName, + Players = parsedData.Players, + Duration = duration, + GameDate = gameDate, + GameType = gameType, + FileSizeBytes = fileInfo.Length, + IsParsed = true, + OriginalFilePath = filePath, + GameVersion = string.IsNullOrWhiteSpace(gameVersion) ? null : gameVersion, + BuildDate = string.IsNullOrWhiteSpace(buildDate) ? null : buildDate, + GameMode = parsedData.GameMode, + StartingCredits = parsedData.StartingCredits, + FogOfWar = parsedData.FogOfWar, + GameSpeed = parsedData.GameSpeed, + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Error parsing replay file: {FilePath}", filePath); + + long fileSize = 0; + try + { + var errorFileInfo = new FileInfo(filePath); + if (errorFileInfo.Exists) + { + fileSize = errorFileInfo.Length; + } + } + catch + { + // Path may be invalid; use 0 as fallback + } + + return CreateEmptyMetadata(filePath, fileSize, gameType); + } + } + + private sealed class ParsedMatchData + { + public string? MapName { get; set; } + + public List? Players { get; set; } + + public string? GameMode { get; set; } + + public int? StartingCredits { get; set; } + + public bool? FogOfWar { get; set; } + + public string? GameSpeed { get; set; } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs new file mode 100644 index 000000000..548e7a781 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Service for saving replay files with metadata-based naming. +/// +/// The directory service. +/// The parser service. +/// The logger instance. +public sealed class ReplaySaveService( + IReplayDirectoryService directoryService, + ReplayParserService parserService, + ILogger logger) +{ + /// + /// Saves a replay file to the Saved directory with metadata-based naming. + /// + /// The source replay file path. + /// The game type. + /// A tuple of the saved file path and parsed metadata, or nulls if save failed. + public async Task<(string? SavedFilePath, ReplayMetadata? Metadata)> SaveReplayAsync(string sourceFilePath, GameType gameType) + { + try + { + if (!File.Exists(sourceFilePath)) + { + logger.LogWarning("Source replay file not found: {FilePath}", sourceFilePath); + return (null, null); + } + + var fileInfo = new FileInfo(sourceFilePath); + if (fileInfo.Length < ReplayManagerConstants.MinimumReplayFileSizeBytes) + { + logger.LogWarning("Replay file too small to save: {FilePath} ({Size} bytes)", sourceFilePath, fileInfo.Length); + return (null, null); + } + + var metadata = await parserService.ParseReplayAsync(sourceFilePath, gameType); + + var replayDirectory = directoryService.GetReplayDirectory(gameType); + var savedDirectory = Path.Combine(replayDirectory, ReplayManagerConstants.SavedReplaysDirectoryName); + + Directory.CreateDirectory(savedDirectory); + + var fileName = GenerateFileName(metadata); + var destinationPath = Path.Combine(savedDirectory, fileName); + + destinationPath = CopyWithRetry(sourceFilePath, destinationPath); + + logger.LogInformation("Saved replay: {Source} -> {Destination}", sourceFilePath, destinationPath); + + return (destinationPath, metadata); + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving replay file: {FilePath}", sourceFilePath); + return (null, null); + } + } + + private static string GenerateFileName(ReplayMetadata metadata) + { + var timestamp = metadata.GameDate ?? DateTime.Now; + var dateString = timestamp.ToString(ReplayManagerConstants.SavedReplayDateFormat, CultureInfo.InvariantCulture); + + var components = new List { dateString }; + + if (metadata.IsParsed) + { + if (!string.IsNullOrWhiteSpace(metadata.MapName)) + { + var sanitizedMapName = SanitizeFileName(metadata.MapName); + if (!string.IsNullOrEmpty(sanitizedMapName)) + { + components.Add(sanitizedMapName); + } + } + + if (metadata.Players != null && metadata.Players.Count > 0) + { + var sanitizedNames = metadata.Players + .Take(ReplayManagerConstants.MaxPlayersInSavedFileName) + .Select(p => SanitizeFileName(p.Name)) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + var playerNames = string.Join("-", sanitizedNames); + if (!string.IsNullOrWhiteSpace(playerNames)) + { + components.Add(playerNames); + } + } + } + + var fileName = string.Join("_", components) + ".rep"; + + // Reserve space for potential _counter suffix (e.g., _999 = 4 chars) + var maxCounterLength = $"_{ReplayManagerConstants.MaxUniquePathAttempts}".Length; + var maxBaseLength = ReplayManagerConstants.MaxFileNameLength - 4 - maxCounterLength; // 4 for ".rep" + + if (fileName.Length > ReplayManagerConstants.MaxFileNameLength) + { + var baseWithoutExtension = fileName[..^4]; // Remove ".rep" + if (baseWithoutExtension.Length > maxBaseLength) + { + baseWithoutExtension = baseWithoutExtension[..maxBaseLength].TrimEnd('_', '-'); + } + + fileName = baseWithoutExtension + ".rep"; + } + + return fileName; + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = string.Concat(fileName.Where(c => !invalidChars.Contains(c))); + + sanitized = sanitized.Replace(' ', '_'); + + while (sanitized.Contains("__", StringComparison.Ordinal)) + { + sanitized = sanitized.Replace("__", "_"); + } + + return sanitized.Trim('_'); + } + + /// + /// Copies a file to a unique destination path with retry logic to handle TOCTOU races. + /// Uses atomic FileMode.CreateNew to avoid race conditions. + /// + private static string CopyWithRetry(string sourceFilePath, string destinationPath) + { + // Open source file once outside retry loop - if it fails, propagate immediately + using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + for (var attempt = 0; attempt < ReplayManagerConstants.SaveRetryMaxAttempts; attempt++) + { + var candidatePath = GetUniqueFilePath(destinationPath); + try + { + // Use FileMode.CreateNew for atomic file creation (fails if file exists) + using var destStream = new FileStream(candidatePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + sourceStream.Position = 0; // Reset stream position for retry attempts + sourceStream.CopyTo(destStream); + return candidatePath; + } + catch (IOException) when (attempt < ReplayManagerConstants.SaveRetryMaxAttempts - 1) + { + // Destination collision — retry with a new unique path + } + catch (IOException) when (attempt >= ReplayManagerConstants.SaveRetryMaxAttempts - 1) + { + throw new IOException( + $"Failed to copy replay file after {ReplayManagerConstants.SaveRetryMaxAttempts} attempts - destination path collision"); + } + catch + { + // Clean up partially created destination file on non-IOException failures + try + { + if (File.Exists(candidatePath)) + { + File.Delete(candidatePath); + } + } + catch + { + // Ignore cleanup failures + } + + throw; + } + } + + throw new InvalidOperationException("Retry loop exited unexpectedly"); + } + + private static string GetUniqueFilePath(string filePath) + { + if (!File.Exists(filePath)) + { + return filePath; + } + + var directory = Path.GetDirectoryName(filePath) ?? string.Empty; + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); + var extension = Path.GetExtension(filePath); + + var counter = 1; + string uniquePath; + + do + { + uniquePath = Path.Combine(directory, $"{fileNameWithoutExtension}_{counter}{extension}"); + counter++; + } + while (File.Exists(uniquePath) && counter <= ReplayManagerConstants.MaxUniquePathAttempts); + + return uniquePath; + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs index 2522ad737..c08ccca5f 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -11,6 +11,7 @@ using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Features.Tools.ReplayManager.Services; using GenHub.Features.Tools.ViewModels; using Microsoft.Extensions.Logging; using System; @@ -30,6 +31,7 @@ namespace GenHub.Features.Tools.ReplayManager.ViewModels; /// The import service. /// The export service. /// The upload history and rate limit service. +/// The replay parser service. /// The notification service. /// The logger instance. public partial class ReplayManagerViewModel( @@ -37,6 +39,7 @@ public partial class ReplayManagerViewModel( IReplayImportService importService, IReplayExportService exportService, IUploadHistoryService uploadHistoryService, + ReplayParserService parserService, INotificationService notificationService, ILogger logger) : ObservableObject { @@ -823,4 +826,70 @@ partial void OnSelectedTabChanged(GameType value) // Load replays for the new tab _ = LoadReplaysAsync(); } + + /// + /// Command to parse and view a replay file. + /// + /// The replay file to parse. + [RelayCommand] + private async Task ParseReplayAsync(ReplayFile? replay) + { + if (replay == null) + { + return; + } + + try + { + IsBusy = true; + StatusMessage = "Parsing replay..."; + + // Use the replay's own game type context, not the UI tab state + var metadata = await parserService.ParseReplayAsync(replay.FullPath, replay.GameVersion); + + if (!metadata.IsParsed) + { + notificationService.ShowWarning("Parse Failed", "Could not parse replay file. The file may be corrupted or in an unsupported format."); + StatusMessage = "Parse failed."; + return; + } + + // Get main window + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = desktop.MainWindow; + if (mainWindow != null) + { + var viewModel = new ReplayViewerViewModel(metadata, replay.FullPath); + var window = new Views.ReplayViewerWindow + { + DataContext = viewModel, + WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterOwner, + }; + + await window.ShowDialog(mainWindow); + } + else + { + notificationService.ShowWarning("Cannot Open Viewer", "No main window is available to display the replay details."); + } + } + else + { + notificationService.ShowWarning("Cannot Open Viewer", "Desktop application lifetime is not available."); + } + + StatusMessage = "Ready"; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse replay: {FullPath}", replay.FullPath); + notificationService.ShowError("Parse Error", $"Failed to parse replay: {ex.Message}"); + StatusMessage = "Parse error."; + } + finally + { + IsBusy = false; + } + } } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs new file mode 100644 index 000000000..ea0611212 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Features.Tools.ReplayManager.ViewModels; + +/// +/// ViewModel for the replay viewer window that displays parsed replay metadata. +/// +public sealed partial class ReplayViewerViewModel : ObservableObject +{ + private static string FormatFileSize(long bytes) + { + if (bytes < 1024) + { + return $"{bytes} B"; + } + + if (bytes < 1024 * 1024) + { + return $"{bytes / 1024.0:F2} KB"; + } + + return $"{bytes / (1024.0 * 1024.0):F2} MB"; + } + + private static string FormatDuration(TimeSpan? duration) + { + if (duration == null) + { + return "Unknown"; + } + + var ts = duration.Value; + if (ts.TotalHours >= 1) + { + return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s"; + } + + if (ts.TotalMinutes >= 1) + { + return $"{ts.Minutes}m {ts.Seconds}s"; + } + + return $"{ts.Seconds}s"; + } + + /// + /// Event raised when the window should be closed. + /// + public event EventHandler? CloseRequested; + + /// + /// Gets the replay metadata to display. + /// + public ReplayMetadata Metadata { get; } + + /// + /// Gets the file path of the replay. + /// + public string FilePath { get; } + + /// + /// Gets the file name without path. + /// + public string FileName => Path.GetFileName(FilePath); + + /// + /// Gets the formatted file size. + /// + public string FormattedFileSize => FormatFileSize(Metadata.FileSizeBytes); + + /// + /// Gets the formatted duration. + /// + public string FormattedDuration => FormatDuration(Metadata.Duration); + + /// + /// Gets the formatted game date. + /// + public string FormattedGameDate => Metadata.GameDate?.ToString("yyyy-MM-dd HH:mm:ss") ?? "Unknown"; + + /// + /// Gets the player list. + /// + public IReadOnlyList Players => Metadata.Players ?? Array.Empty(); + + /// + /// Gets the player count. + /// + public int PlayerCount => Players.Count; + + /// + /// Gets the shortened map name for display in stat cards. + /// + public string MapNameShort + { + get + { + var mapName = Metadata.MapName; + if (string.IsNullOrEmpty(mapName)) + { + return "Unknown"; + } + + // Truncate long map names for the stat card + return mapName.Length > 20 ? mapName[..17] + "..." : mapName; + } + } + + /// + /// Gets the parse status text. + /// + public string ParseStatusText => Metadata.IsParsed ? "Parsed" : "Parse Failed"; + + /// + /// Gets the parse status icon. + /// + public string ParseStatusIcon => Metadata.IsParsed ? "✓" : "✗"; + + /// + /// Gets the parse status color. + /// + public IBrush ParseStatusColor => Metadata.IsParsed + ? new SolidColorBrush(Color.Parse("#4CAF50")) + : new SolidColorBrush(Color.Parse("#F44336")); + + /// + /// Gets a value indicating whether game settings information is available. + /// + public bool HasGameSettings => Metadata.GameMode != null || Metadata.StartingCredits != null || + Metadata.FogOfWar != null || Metadata.GameSpeed != null; + + /// + /// Gets the formatted starting credits. + /// + public string FormattedStartingCredits => Metadata.StartingCredits?.ToString("N0") ?? "Unknown"; + + /// + /// Gets the fog of war status text. + /// + public string FogOfWarText => Metadata.FogOfWar switch + { + true => "Enabled", + false => "Disabled", + null => "Unknown", + }; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed replay metadata. + /// The file path of the replay. + public ReplayViewerViewModel(ReplayMetadata metadata, string filePath) + { + Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + } + + /// + /// Command to close the window. + /// + [RelayCommand] + private void Close() + { + CloseRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml index b08e8f05a..993af4973 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml @@ -80,10 +80,9 @@ -