Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
53fab46
feat(replay-manager): add automatic replay saving feature
Feb 15, 2026
0ea7d19
feat(replay-manager): enhance replay viewer UI with modern design
Mar 12, 2026
b4087d6
feat(replay-parser): enhance parser with detailed game information an…
Mar 12, 2026
4fd49e0
feat(replay-manager): add value converters for enhanced player display
Mar 12, 2026
9218ebf
fix: address CodeRabbit review comments
Mar 12, 2026
5c79ddc
fix: resolve Greptile bot review comments and build errors
Mar 12, 2026
66d649a
fix: resolve Greptile null insertion and field parsing issues
Mar 12, 2026
6831c8c
fix: resolve final Greptile review comments
Mar 12, 2026
2816049
fix: prevent trailing dash in filenames from empty sanitized player n…
Mar 12, 2026
320d6c5
fix: resolve critical build error and StyleCop warnings (part 1)
Mar 13, 2026
12b37f6
fix: add filename constants and fix sanitized map name check
Mar 13, 2026
c2c8e53
fix: apply comprehensive StyleCop fixes (part 2)
Mar 13, 2026
0f68bfd
fix: add blank lines between ParsedMatchData properties (SA1516)
Mar 13, 2026
f375c9f
fix: add debug logging to parser to diagnose parsing issues
Mar 13, 2026
cbfd716
Fix build errors and StyleCop warnings in replay parser
Mar 13, 2026
6aa1fd4
Fix duplicate method definition - rename to CreateEmptyMetadata
Mar 13, 2026
7373719
Fix SA1204 and SA1507: move static methods before instance methods
Mar 13, 2026
cc16a81
Fix resource leak issues in ReplayMonitor and ReplayMonitoringService
Mar 13, 2026
95cb670
Remove unused MaxFileNameLengthBeforeExtension constant
Mar 13, 2026
cdf537c
Fix watcher restart failure leaving monitor in inconsistent state
Mar 13, 2026
408ae39
Fix incorrect GameType parameter in ParseReplayAsync
Mar 13, 2026
9e914d6
Fix FileShare inconsistency in ReplaySaveService
Mar 13, 2026
7a57382
Fix orphaned session and IOException masking issues
Mar 13, 2026
a5c90ba
Fix bogus duration when beginTimestamp is zero
Mar 13, 2026
38dbd11
Fix code review comments: hide AutoSaveReplays for tool profiles and …
Mar 16, 2026
d320b9e
fix: prevent infinite timer rearming for sub-minimum-size stable repl…
Mar 16, 2026
5dc985f
fix: address code review comments - remove dead null check, add viewe…
Mar 16, 2026
0852f89
fix: address replay service review comments - proper async event hand…
Mar 16, 2026
8f76b26
fix: resolve build errors - add missing ReplayParserService parameter…
Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,72 @@ public static class ReplayManagerConstants
/// Default file name for imported replays.
/// </summary>
public const string DefaultImportedReplayFileName = "imported_replay.rep";
}

/// <summary>
/// Name of the subdirectory for auto-saved replays.
/// </summary>
public const string SavedReplaysDirectoryName = "Saved";

/// <summary>
/// Default replay file name that gets overwritten by the game.
/// </summary>
public const string DefaultReplayFileName = "00000000.rep";

/// <summary>
/// Interval in milliseconds for checking file stability.
/// </summary>
public const int FileStabilityCheckIntervalMs = 2000;

/// <summary>
/// Number of consecutive stability checks required before considering file complete.
/// </summary>
public const int FileStabilityCheckCount = 3;

/// <summary>
/// Minimum file size in bytes to consider a replay valid (10 KB).
/// </summary>
public const long MinimumReplayFileSizeBytes = 10 * 1024;

/// <summary>
/// Magic bytes for replay file header validation.
/// </summary>
public const string ReplayMagicBytes = "GENREP";

/// <summary>
/// Date format for auto-saved replay filenames.
/// </summary>
public const string SavedReplayDateFormat = "yyyyMMdd_HHmmss";

/// <summary>
/// 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.
/// </summary>
public const int StopMonitoringGracePeriodSeconds = 14;

/// <summary>
/// Maximum number of retry attempts when saving a replay file encounters an I/O conflict.
/// </summary>
public const int SaveRetryMaxAttempts = 3;

/// <summary>
/// Maximum byte length for reading null-terminated strings from replay files.
/// </summary>
public const int MaxStringReadBytes = 4096;

/// <summary>
/// Maximum number of uniqueness suffix attempts when finding a non-colliding file path.
/// </summary>
public const int MaxUniquePathAttempts = 1000;

/// <summary>
/// Maximum number of players included in auto-save filename components.
/// </summary>
public const int MaxPlayersInSavedFileName = 4;

/// <summary>
/// Maximum filename length in bytes (Windows/most POSIX filesystems).
/// </summary>
public const int MaxFileNameLength = 255;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Events;

namespace GenHub.Core.Interfaces.Tools.ReplayManager;

/// <summary>
/// Service for monitoring replay files and automatically saving them.
/// </summary>
public interface IReplayMonitorService
{
/// <summary>
/// Event raised when a replay file has been completed and saved.
/// </summary>
event EventHandler<ReplayCompletedEventArgs>? ReplayCompleted;

/// <summary>
/// Starts monitoring for a specific profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="gameType">The game type.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StartMonitoringAsync(string profileId, GameType gameType);

/// <summary>
/// Stops monitoring for a specific profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopMonitoringAsync(string profileId);

/// <summary>
/// Stops all active monitoring.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopAllMonitoringAsync();
Comment on lines +22 to +35

@coderabbitai coderabbitai Bot Feb 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding CancellationToken parameters to async methods.

None of the async methods accept a CancellationToken. For a monitoring service that may be stopped during app shutdown, supporting cancellation is important for graceful teardown. This is especially relevant for StopAllMonitoringAsync which may be called during application exit.

Proposed change
-    Task StartMonitoringAsync(string profileId, GameType gameType);
+    Task StartMonitoringAsync(string profileId, GameType gameType, CancellationToken cancellationToken = default);

-    Task StopMonitoringAsync(string profileId);
+    Task StopMonitoringAsync(string profileId, CancellationToken cancellationToken = default);

-    Task StopAllMonitoringAsync();
+    Task StopAllMonitoringAsync(CancellationToken cancellationToken = default);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Task StartMonitoringAsync(string profileId, GameType gameType);
/// <summary>
/// Stops monitoring for a specific profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopMonitoringAsync(string profileId);
/// <summary>
/// Stops all active monitoring.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopAllMonitoringAsync();
Task StartMonitoringAsync(string profileId, GameType gameType, CancellationToken cancellationToken = default);
/// <summary>
/// Stops monitoring for a specific profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopMonitoringAsync(string profileId, CancellationToken cancellationToken = default);
/// <summary>
/// Stops all active monitoring.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopAllMonitoringAsync(CancellationToken cancellationToken = default);
🤖 Prompt for AI Agents
In `@GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs`
around lines 22 - 35, Add CancellationToken parameters to the async methods in
IReplayMonitorService: change StartMonitoringAsync(string profileId, GameType
gameType) to include a CancellationToken (e.g., CancellationToken
cancellationToken = default), and likewise add CancellationToken parameters to
StopMonitoringAsync(string profileId) and StopAllMonitoringAsync() so callers
can cancel long-running monitoring/shutdown operations; update the XML doc
comments to document the new token and ensure all implementations of
IReplayMonitorService and callers are updated to accept/forward the token (or
use default) to support graceful teardown during app shutdown.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Valid point for future enhancement. The current methods are synchronous returning Task.CompletedTask, so cancellation tokens would have no practical effect yet. Will add when we introduce actual async operations (e.g., live spectating via replay sharing).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@undead2146, thank you for the explanation! That makes sense—deferring cancellation tokens until there are actual async operations to cancel is a pragmatic approach. Looking forward to seeing them added when the live spectating feature is implemented. 👍


✏️ Learnings added
Learnt from: undead2146
Repo: community-outpost/GenHub PR: 275
File: GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs:22-35
Timestamp: 2026-02-15T03:56:51.952Z
Learning: In the GenHub codebase, CancellationToken parameters are deferred on async methods until actual asynchronous operations are introduced. Methods currently returning Task.CompletedTask do not require cancellation tokens.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Will add CancellationToken parameters when actual async operations are introduced (e.g., live spectating).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@undead2146, understood! Thank you for confirming the approach. The decision to add CancellationToken parameters when they'll have practical benefit (with actual async operations) makes sense. 👍


🧠 Learnings used
Learnt from: undead2146
Repo: community-outpost/GenHub PR: 275
File: GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs:22-35
Timestamp: 2026-02-15T03:56:51.952Z
Learning: In the GenHub codebase, CancellationToken parameters are deferred on async methods until actual asynchronous operations are introduced. Methods currently returning Task.CompletedTask do not require cancellation tokens.


/// <summary>
/// Stops monitoring for a specific profile, verifying session identity.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="sessionId">If provided, only stops if the active session matches this ID.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task StopMonitoringAsync(string profileId, string? sessionId);

/// <summary>
/// Gets a value indicating whether monitoring is active for a profile.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <returns>True if monitoring is active; otherwise, false.</returns>
bool IsMonitoring(string profileId);

/// <summary>
/// Gets the session ID for an active monitoring session.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <returns>The session ID, or null if not monitoring.</returns>
string? GetSessionId(string profileId);
}
Comment on lines +9 to +58

@coderabbitai coderabbitai Bot Feb 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extending IAsyncDisposable for resource cleanup.

The implementation manages FileSystemWatcher instances and per-profile monitoring state. Making the interface extend IAsyncDisposable would ensure deterministic cleanup of these resources and align with .NET conventions for services holding unmanaged resources. The DI container would then automatically dispose it on shutdown.

🤖 Prompt for AI Agents
In `@GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs`
around lines 9 - 43, Update the IReplayMonitorService interface to extend
IAsyncDisposable by adding a ValueTask DisposeAsync() contract so
implementations (e.g., the class that manages FileSystemWatcher instances and
per-profile monitoring state) can deterministically release unmanaged resources;
ensure implementations perform async cleanup of FileSystemWatcher instances and
any per-profile state inside DisposeAsync so the DI container can dispose the
service on shutdown.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is a valid suggestion for future improvement. For now, the implementation uses IDisposable consistently with how other services in GenHub handle resource cleanup. The DI container calls Dispose on singleton services at shutdown.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@undead2146, thank you for the clarification! That makes sense—maintaining consistency with the existing service patterns in GenHub is a valid approach, especially when the DI container already handles disposal properly at shutdown.


✏️ Learnings added
Learnt from: undead2146
Repo: community-outpost/GenHub PR: 275
File: GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs:9-43
Timestamp: 2026-02-15T03:56:47.213Z
Learning: GenHub services consistently use `IDisposable` rather than `IAsyncDisposable` for resource cleanup. The DI container calls `Dispose` on singleton services at shutdown, providing deterministic cleanup without requiring async disposal patterns.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Valid suggestion for future improvement. Currently using IDisposable consistently with other GenHub services.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@undead2146, understood! Maintaining consistency across the codebase is a solid approach. The existing IDisposable pattern with DI container disposal at shutdown works well for your use case.


🧠 Learnings used
Learnt from: undead2146
Repo: community-outpost/GenHub PR: 275
File: GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs:9-43
Timestamp: 2026-02-15T03:56:47.213Z
Learning: GenHub services consistently use `IDisposable` rather than `IAsyncDisposable` for resource cleanup. The DI container calls `Dispose` on singleton services at shutdown, providing deterministic cleanup without requiring async disposal patterns.

24 changes: 24 additions & 0 deletions GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using GenHub.Core.Models.Tools.ReplayManager;

namespace GenHub.Core.Models.Events;

/// <summary>
/// Event arguments for replay completion.
/// </summary>
public sealed class ReplayCompletedEventArgs : EventArgs
{
/// <summary>
/// Gets the profile ID.
/// </summary>
public required string ProfileId { get; init; }

/// <summary>
/// Gets the saved replay file path.
/// </summary>
public required string SavedFilePath { get; init; }

/// <summary>
/// Gets the replay metadata.
/// </summary>
public required ReplayMetadata Metadata { get; init; }
}
12 changes: 12 additions & 0 deletions GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace GenHub.Core.Models.Events;

/// <summary>
/// Event arguments for replay file completion.
/// </summary>
public sealed class ReplayFileCompletedEventArgs : EventArgs
{
/// <summary>
/// Gets the completed file path.
/// </summary>
public required string FilePath { get; init; }
}
3 changes: 3 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public class CreateProfileRequest
/// <summary>Gets or sets the command line arguments to pass to the game executable.</summary>
public string? CommandLineArguments { get; set; }

/// <summary>Gets or sets a value indicating whether to automatically save replays for this profile.</summary>
public bool AutoSaveReplays { get; set; }

/// <summary>Gets or sets the IP address for GameSpy/Networking services.</summary>
public string? GameSpyIPAddress { get; set; }

Expand Down
5 changes: 5 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ public class GameProfile : IGameProfile
/// <summary>Gets or sets a value indicating whether props are shown.</summary>
public bool? VideoShowProps { get; set; }

// ===== GenHub Features =====

/// <summary>Gets or sets a value indicating whether to automatically save replays for this profile.</summary>
public bool AutoSaveReplays { get; set; }

// ===== TheSuperHackers Client Settings =====

/// <summary>Gets or sets a value indicating whether to archive replays automatically (TSH).</summary>
Expand Down
5 changes: 5 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ public class UpdateProfileRequest
/// </summary>
public string? CommandLineArguments { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to automatically save replays for this profile.
/// </summary>
public bool? AutoSaveReplays { get; set; }

/// <summary>
/// Gets or sets the video resolution width for this profile.
/// </summary>
Expand Down
42 changes: 42 additions & 0 deletions GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace GenHub.Core.Models.Tools.ReplayManager;

/// <summary>
/// Information about a player in a replay.
/// </summary>
public sealed class PlayerInfo
{
/// <summary>
/// Gets the player name.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// Gets the player type (Human or AI).
/// </summary>
public required PlayerType Type { get; init; }

/// <summary>
/// Gets the faction/side (e.g., USA, China, GLA).
/// </summary>
public string? Faction { get; init; }

/// <summary>
/// Gets the team number.
/// </summary>
public int? Team { get; init; }

/// <summary>
/// Gets the player color.
/// </summary>
public string? Color { get; init; }

/// <summary>
/// Gets the AI difficulty (for AI players).
/// </summary>
public string? AiDifficulty { get; init; }

/// <summary>
/// Gets the starting position/slot number.
/// </summary>
public int? StartPosition { get; init; }
}
22 changes: 22 additions & 0 deletions GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace GenHub.Core.Models.Tools.ReplayManager;

/// <summary>
/// Player type enumeration.
/// </summary>
public enum PlayerType
{
/// <summary>
/// Human player.
/// </summary>
Human,

/// <summary>
/// AI/Computer player.
/// </summary>
Computer,

/// <summary>
/// Observer/spectator.
/// </summary>
Observer,
}
60 changes: 56 additions & 4 deletions GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using GenHub.Core.Models.Enums;

namespace GenHub.Core.Models.Tools.ReplayManager;

/// <summary>
/// Placeholder for future replay parsing feature.
/// Metadata extracted from a replay file.
/// </summary>
public sealed class ReplayMetadata
{
Expand All @@ -11,9 +13,9 @@ public sealed class ReplayMetadata
public string? MapName { get; init; }

/// <summary>
/// Gets the list of players.
/// Gets the list of player information.
/// </summary>
public IReadOnlyList<string>? Players { get; init; }
public IReadOnlyList<PlayerInfo>? Players { get; init; }

/// <summary>
/// Gets the game duration.
Expand All @@ -24,4 +26,54 @@ public sealed class ReplayMetadata
/// Gets the date the game was played.
/// </summary>
public DateTime? GameDate { get; init; }
}

/// <summary>
/// Gets the game type (Generals or Zero Hour).
/// </summary>
public GameType? GameType { get; init; }

/// <summary>
/// Gets the file size in bytes.
/// </summary>
public long FileSizeBytes { get; init; }

/// <summary>
/// Gets a value indicating whether the replay was successfully parsed.
/// </summary>
public bool IsParsed { get; init; }

/// <summary>
/// Gets the original file path.
/// </summary>
public string? OriginalFilePath { get; init; }

/// <summary>
/// Gets the game mode (e.g., Skirmish, Online).
/// </summary>
public string? GameMode { get; init; }

/// <summary>
/// Gets the game version string.
/// </summary>
public string? GameVersion { get; init; }

/// <summary>
/// Gets the build date string.
/// </summary>
public string? BuildDate { get; init; }

/// <summary>
/// Gets the starting credits for the match.
/// </summary>
public int? StartingCredits { get; init; }

/// <summary>
/// Gets whether fog of war was enabled.
/// </summary>
public bool? FogOfWar { get; init; }

/// <summary>
/// Gets the game speed setting.
/// </summary>
public string? GameSpeed { get; init; }
}
Loading
Loading