Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public static class GeneralsOnlineConstants
public const string VersionDateFormat = "MMddyy";

/// <summary>Separator between date and QFE number in versions.</summary>
/// <remarks>Used by catalog parser for date extraction. Version comparison uses underscore split.</remarks>
public const string QfeSeparator = "_QFE";

/// <summary>Prefix for QFE markers in version strings.</summary>
Expand Down
21 changes: 16 additions & 5 deletions GenHub/GenHub.Core/Helpers/GameVersionHelper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq;
using System.Text.RegularExpressions;
using GenHub.Core.Constants;

namespace GenHub.Core.Helpers;

Expand Down Expand Up @@ -122,7 +123,9 @@ public static int NormalizeVersion(string? version)
}

/// <summary>
/// Parses a version string (MMDDYY_QFE#) used by Generals Online.
/// Parses a version string (MMDDYY_QFE#[_SUFFIX]) used by Generals Online.
/// Supports extended formats like "042826_QFE3_EAC" — extra segments after
/// the QFE number are build metadata and do not affect version ordering.
/// </summary>
/// <param name="version">The version string to parse.</param>
/// <returns>A tuple containing the extracted date and QFE number, or null if parsing fails.</returns>
Expand All @@ -135,15 +138,15 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi

try
{
// Format: MMDDYY_QFE# or DDMMYY_QFE# (General Online CDN uses MMDDYY)
// Format: MMDDYY_QFE#[_SUFFIX...] (Generals Online CDN uses MMDDYY)
var parts = version.Split('_', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
if (parts.Length < 2)
{
return null;
}

var datePart = parts[0];
var qfePart = parts[1].Replace("QFE", string.Empty, StringComparison.OrdinalIgnoreCase);
var qfePart = parts[1].Replace(GeneralsOnlineConstants.QfeMarkerPrefix, string.Empty, StringComparison.OrdinalIgnoreCase);

if (datePart.Length != 6 || !int.TryParse(qfePart, out var qfe))
{
Expand All @@ -164,7 +167,15 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi

/// <summary>
/// Gets a sortable integer version for Generals Online versions.
/// Converts "101525_QFE2" to 1015252.
/// Converts "101525_QFE2" to 1015252, "042826_QFE3_EAC" to 428263.
/// Build suffixes (e.g. _EAC) are ignored — only date and QFE affect ordering.
/// <para>
/// Note: The encoding uses <c>dateValue * 10 + QFE</c>, which limits QFE to 0–9
/// without colliding with the next date. This is intentional — the value is embedded
/// in manifest IDs and changing the encoding would break installed content references.
/// Version ordering uses <see cref="ParseGeneralsOnlineVersion"/> directly via
/// <c>VersionComparer.CompareGeneralsOnlineVersions</c>, which has no such limitation.
/// </para>
/// </summary>
/// <param name="version">The version string to convert.</param>
/// <returns>A sortable integer, or 0 if parsing fails.</returns>
Expand Down
73 changes: 69 additions & 4 deletions GenHub/GenHub.Core/Helpers/VersionComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ public static int CompareVersions(string? version1, string? version2, string? pu
{
// Handle null/empty cases
if (string.IsNullOrWhiteSpace(version1) && string.IsNullOrWhiteSpace(version2))
{
return 0;
}

if (string.IsNullOrWhiteSpace(version1))
{
return -1;
}

if (string.IsNullOrWhiteSpace(version2))
{
return 1;
}

// Determine comparison strategy based on publisher type
if (string.Equals(publisherType, CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase))
Expand All @@ -41,14 +49,53 @@ public static int CompareVersions(string? version1, string? version2, string? pu
}
else if (string.Equals(publisherType, PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase))
{
// GeneralsOnline uses numeric versions
return CompareNumericVersions(version1, version2);
// GeneralsOnline uses MMDDYY_QFE#[_SUFFIX] format — parse for accurate comparison
return CompareGeneralsOnlineVersions(version1, version2);
}

// Default: Use the robust numeric/semantic comparison logic
return CompareNumericVersions(version1, version2);
}

/// <summary>
/// Compares two GeneralsOnline version strings (MMDDYY_QFE#[_SUFFIX]).
/// Extra suffix segments (e.g. _EAC) are build metadata and do not affect ordering.
/// Falls back to <see cref="CompareNumericVersions"/> when parsing fails.
/// </summary>
/// <param name="version1">The first version.</param>
/// <param name="version2">The second version.</param>
/// <returns>Comparison result.</returns>
private static int CompareGeneralsOnlineVersions(string version1, string version2)
{
var parsed1 = GameVersionHelper.ParseGeneralsOnlineVersion(version1);
var parsed2 = GameVersionHelper.ParseGeneralsOnlineVersion(version2);

// Both unparseable — fall back to generic numeric comparison
if (parsed1 == null && parsed2 == null)
{
return CompareNumericVersions(version1, version2);
}

// Exactly one unparseable — treat unparseable as always older
if (parsed1 == null)
{
return -1;
}

if (parsed2 == null)
{
return 1;
}

int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date);
if (dateCompare != 0)
{
return dateCompare;
}

return parsed1.Value.Qfe.CompareTo(parsed2.Value.Qfe);
}

/// <summary>
/// Compares two date-based version strings in YYYY-MM-DD format.
/// </summary>
Expand Down Expand Up @@ -170,22 +217,30 @@ private static int CompareNumericVersions(string ver1, string ver2)
private static string NormalizeVersionString(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return string.Empty;
}

// Remove common prefixes that publishers use
var normalized = version;

// Remove weekly- prefix (SuperHackers)
if (normalized.StartsWith("weekly-", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized.Substring(8); // Remove "weekly-"
}

// Remove release- prefix
if (normalized.StartsWith("release-", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized.Substring(9); // Remove "release-"
}

// Remove version- prefix
if (normalized.StartsWith("version-", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized.Substring(9); // Remove "version-"
}

// Remove 'v' or 'V' prefix
normalized = normalized.TrimStart('v', 'V');
Expand Down Expand Up @@ -237,13 +292,19 @@ private static int CompareSemanticVersions(string ver1, string ver2)

if (isDigit1 && isDigit2 && long.TryParse(segment1, out var num1) && long.TryParse(segment2, out var num2))
{
if (num1 != num2) return num1.CompareTo(num2);
if (num1 != num2)
{
return num1.CompareTo(num2);
}
}
else
{
// For non-pure-numeric segments, compare the full original strings (case-insensitive)
var strCompare = string.Compare(rawSegment1, rawSegment2, StringComparison.OrdinalIgnoreCase);
if (strCompare != 0) return strCompare;
if (strCompare != 0)
{
return strCompare;
}
}
}

Expand Down Expand Up @@ -277,7 +338,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result)
private static long? ExtractNumericFromDate(string dateStr)
{
if (string.IsNullOrWhiteSpace(dateStr))
{
return null;
}

// Remove common date separators
var digits = dateStr.Replace("-", string.Empty)
Expand All @@ -298,7 +361,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result)
private static string ExtractDigits(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return string.Empty;
}

var result = new System.Text.StringBuilder();
foreach (var c in version)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ namespace GenHub.Core.Models.GeneralsOnline;
public class GeneralsOnlineApiResponse
{
/// <summary>
/// Gets or sets the version string (e.g., "111825_QFE2" for November 18, 2025).
/// Format: MMDDYY_QFE# where MM=month, DD=day, YY=year, #=QFE number.
/// Gets or sets the version string (e.g., "111825_QFE2" or "042826_QFE3_EAC").
/// Format: MMDDYY_QFE#[_SUFFIX] where MM=month, DD=day, YY=year, #=QFE number.
/// The optional suffix (e.g. "_EAC") is build metadata that does not affect version ordering.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace GenHub.Core.Models.GeneralsOnline;
public class GeneralsOnlineRelease
{
/// <summary>
/// Gets version string in format: MMDDYY_QFE# (e.g., "101525_QFE5").
/// Gets version string in format: MMDDYY_QFE#[_SUFFIX] (e.g., "101525_QFE5", "042826_QFE3_EAC").
/// </summary>
public required string Version { get; init; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Providers;
using GenHub.Core.Models.GeneralsOnline;
using GenHub.Core.Models.Providers;
using GenHub.Features.Content.Services.GeneralsOnline;
using Microsoft.Extensions.Logging.Abstractions;
Expand Down Expand Up @@ -91,4 +92,51 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly()
var item = result.Data.First();
Assert.Equal("111825_QFE2", item.Version);
}

/// <summary>
/// Tests that ParseAsync correctly parses extended version format with build suffix.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task ParseAsync_WithExtendedVersion_ParsesCorrectly()
{
// Arrange
var json = @"{
""version"": ""042826_QFE3_EAC"",
""download_url"": ""https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip"",
""size"": 28277693,
""release_notes"": ""www.playgenerals.online/updates"",
""sha256"": ""abc123""
}";

var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}";

// Act
var result = await _parser.ParseAsync(wrapper, _provider);

// Assert
Assert.True(result.Success);
var item = result.Data!.First();
Assert.Equal("042826_QFE3_EAC", item.Version);
Assert.Equal("https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip", item.GetData<GeneralsOnlineRelease>()!.PortableUrl);
}

/// <summary>
/// Tests that ParseAsync correctly handles latest.txt fallback with extended version.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task ParseAsync_LatestFallbackExtendedVersion_ParsesCorrectly()
{
// Arrange
var wrapper = "{\"source\":\"latest\",\"version\":\"042826_QFE3_EAC\"}";

// Act
var result = await _parser.ParseAsync(wrapper, _provider);

// Assert
Assert.True(result.Success);
var item = result.Data!.First();
Assert.Equal("042826_QFE3_EAC", item.Version);
}
}
Loading
Loading