Skip to content
Closed
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
85 changes: 85 additions & 0 deletions Emerald.CoreX.Tests/Models/GameInstallVersionResolutionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Emerald.CoreX;
using Xunit;

namespace Emerald.CoreX.Tests.Models;

public sealed class GameInstallVersionResolutionTests
{
[Fact]
public void ResolveOfflineInstalledVersionName_PrefersSavedRealVersion()
{
var version = new Versions.Version
{
Type = Versions.Type.Fabric,
BasedOn = "1.21.4",
ModVersion = "0.16.10",
RealVersion = "fabric-loader-0.16.10-1.21.4",
DisplayName = "Fabric"
};

var resolved = Game.ResolveOfflineInstalledVersionName(
["fabric-loader-0.16.10-1.21.4", "fabric-loader-0.16.11-1.21.4"],
version,
"fabric-loader-0.16.11-1.21.4");

Assert.Equal("fabric-loader-0.16.10-1.21.4", resolved);
}

[Fact]
public void ResolveOfflineInstalledVersionName_UsesRoutedVersion_WhenSavedRealVersionIsMissing()
{
var version = new Versions.Version
{
Type = Versions.Type.Quilt,
BasedOn = "1.20.1",
ModVersion = "0.22.0",
DisplayName = "Quilt"
};

var resolved = Game.ResolveOfflineInstalledVersionName(
["quilt-loader-0.22.0-1.20.1"],
version,
"quilt-loader-0.22.0-1.20.1");

Assert.Equal("quilt-loader-0.22.0-1.20.1", resolved);
}

[Fact]
public void ResolveOfflineInstalledVersionName_InfersSingleLoaderVersion_WhenExactCandidateIsMissing()
{
var version = new Versions.Version
{
Type = Versions.Type.Forge,
BasedOn = "1.20.1",
ModVersion = "47.4.0",
DisplayName = "Forge"
};

var resolved = Game.ResolveOfflineInstalledVersionName(
["1.20.1-forge-47.4.0", "1.20.1"],
version,
null);

Assert.Equal("1.20.1-forge-47.4.0", resolved);
}

[Fact]
public void ResolveOfflineInstalledVersionName_IgnoresEmptyCandidates_AndReturnsNullWhenNoInstalledMatchExists()
{
var version = new Versions.Version
{
Type = Versions.Type.Fabric,
BasedOn = "1.21.4",
ModVersion = "0.16.10",
RealVersion = "",
DisplayName = "Fabric"
};

var resolved = Game.ResolveOfflineInstalledVersionName(
["1.21.4"],
version,
"");

Assert.Null(resolved);
}
}
2 changes: 1 addition & 1 deletion Emerald.CoreX/Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null)
}
catch (HttpRequestException)
{
IsOfflineMode = false;
IsOfflineMode = true;
_notify.Complete(not.Id, true,"OfflineMode");
}
catch (Exception ex)
Expand Down
218 changes: 183 additions & 35 deletions Emerald.CoreX/Game.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public partial class Game : ObservableObject

public string? SharedMinecraftBasePath => _sharedMinecraftBasePath;

public bool IsOfflineMode => _launcherOfflineMode;

[ObservableProperty]
private bool _usesCustomGameSettings;

Expand Down Expand Up @@ -168,6 +170,7 @@ public async Task InstallVersion(bool isOffline = false, bool showFileProgress =

public async Task InstallVersionOrThrow(bool isOffline = false, bool showFileProgress = false)
{
EnsureMinecraftBasePathExists();
_logger.LogInformation("Starting InstallVersion with isOffline: {IsOffline}, showFileProgress: {ShowFileProgress}", isOffline, showFileProgress);
CreateMCLauncher(isOffline);

Expand All @@ -186,10 +189,8 @@ public async Task InstallVersionOrThrow(bool isOffline = false, bool showFilePro

try
{
string? ver = await Ioc.Default.GetService<Installers.ModLoaderRouter>().RouteAndInitializeAsync(Path, Version);
_logger.LogInformation("Version initialization completed. Version: {Version}", ver);

if (ver == null)
var ver = await ResolveInstallVersionAsync(isOffline);
if (string.IsNullOrWhiteSpace(ver))
{
_logger.LogWarning("Version {VersionType} {ModVersion} {BasedOn} not found.", Version.Type, Version.ModVersion, Version.BasedOn);

Expand All @@ -201,31 +202,6 @@ public async Task InstallVersionOrThrow(bool isOffline = false, bool showFilePro

throw new InvalidOperationException($"Version {Version.Type} {Version.ModVersion} {Version.BasedOn} not found.");
}
if (isOffline)
{
_logger.LogDebug("Validating version {Version} against the local offline manifest cache.", ver);
var vers = await Launcher.GetAllVersionsAsync();
var mver = vers.Where(x => x.Name == ver).First();
if (mver == null)
{
_logger.LogWarning("Version {Version} not found in offline mode. Can't proceed installation.", ver);
throw new NullReferenceException($"Version {ver} not found in offline mode. Can't proceed installation.");
}
}

Version.RealVersion = ver;

if (isOffline)
{
_logger.LogDebug("Rechecking offline version {Version} before install.", ver);
var vers = await Launcher.GetAllVersionsAsync();
var mver = vers.Where(x => x.Name == ver).First();
if (mver == null)
{
_logger.LogWarning("Version {Version} not found in offline mode. Can't proceed installation.", ver);
throw new NullReferenceException($"Version {ver} not found in offline mode. Can't proceed installation.");
}
}

(string Files, string bytes, double prog, double? progbytes) prog = (string.Empty, string.Empty, 0, null);

Expand Down Expand Up @@ -266,6 +242,7 @@ await Launcher.InstallAsync(
}),
not.CancellationToken.Value);

Version.RealVersion = ver;
_logger.LogInformation("Version {VersionType} {VersionDisplayName} installation completed successfully.", Version.Type, Version.DisplayName);
_notify.Complete(not.Id, true, $"Finished downloading/verifying {Version.Type} version {Version.DisplayName}");
}
Expand All @@ -277,13 +254,167 @@ await Launcher.InstallAsync(
}
}

private void EnsureMinecraftBasePathExists()
{
Directory.CreateDirectory(Path.BasePath);
}

private async Task<string?> ResolveInstallVersionAsync(bool isOffline)
{
if (isOffline)
{
var existingOfflineVersion = await ResolveExistingOfflineVersionAsync(null);
if (!string.IsNullOrWhiteSpace(existingOfflineVersion))
{
_logger.LogInformation("Using existing offline version {Version}.", existingOfflineVersion);
return existingOfflineVersion;
}
}

var router = Ioc.Default.GetService<Installers.ModLoaderRouter>()
?? throw new InvalidOperationException("Mod loader router service is not available.");
var routedVersion = await router.RouteAndInitializeAsync(Path, Version, online: !isOffline);
_logger.LogInformation("Version initialization completed. Version: {Version}", routedVersion);

if (!isOffline)
{
return string.IsNullOrWhiteSpace(routedVersion) ? null : routedVersion;
}

var offlineVersion = await ResolveExistingOfflineVersionAsync(routedVersion);
if (string.IsNullOrWhiteSpace(offlineVersion))
{
_logger.LogWarning(
"Version {VersionType} {ModVersion} {BasedOn} was not found in the local offline manifest cache. RoutedVersion: {RoutedVersion}. SavedRealVersion: {RealVersion}.",
Version.Type,
Version.ModVersion,
Version.BasedOn,
routedVersion,
Version.RealVersion);
}

return offlineVersion;
}

private async Task<string?> ResolveExistingOfflineVersionAsync(string? routedVersion)
{
_logger.LogDebug("Validating version candidates against the local offline manifest cache.");
var installedVersions = await Launcher.GetAllVersionsAsync();
return ResolveOfflineInstalledVersionName(
installedVersions.Select(version => version.Name),
Version,
routedVersion);
}

internal static string? ResolveOfflineInstalledVersionName(
IEnumerable<string> installedVersionNames,
Versions.Version version,
string? routedVersion)
{
var installed = installedVersionNames
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

foreach (var candidate in GetExactVersionCandidates(version, routedVersion))
{
var exact = installed.FirstOrDefault(name =>
string.Equals(name, candidate, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(exact))
{
return exact;
}
}

if (version.Type == Versions.Type.Vanilla)
{
return null;
}

var inferredMatches = installed
.Where(name => IsLikelyInstalledLoaderVersion(name, version))
.ToArray();

return inferredMatches.Length == 1 ? inferredMatches[0] : null;
}

private static IEnumerable<string> GetExactVersionCandidates(
Versions.Version version,
string? routedVersion)
{
if (!string.IsNullOrWhiteSpace(version.RealVersion))
{
yield return version.RealVersion;
}

if (!string.IsNullOrWhiteSpace(routedVersion))
{
yield return routedVersion;
}

if (version.Type == Versions.Type.Vanilla && !string.IsNullOrWhiteSpace(version.BasedOn))
{
yield return version.BasedOn;
}
}

private static bool IsLikelyInstalledLoaderVersion(string installedVersionName, Versions.Version version)
{
if (string.IsNullOrWhiteSpace(version.BasedOn)
|| !ContainsIgnoreCase(installedVersionName, version.BasedOn)
|| !GetLoaderTokens(version.Type).Any(token => ContainsIgnoreCase(installedVersionName, token)))
{
return false;
}

return string.IsNullOrWhiteSpace(version.ModVersion)
|| ContainsIgnoreCase(installedVersionName, version.ModVersion);
}

private static string[] GetLoaderTokens(Versions.Type type)
=> type switch
{
Versions.Type.Fabric => ["fabric"],
Versions.Type.Forge => ["forge"],
Versions.Type.NeoForge => ["neoforge", "neo-forge"],
Versions.Type.Quilt => ["quilt"],
Versions.Type.LiteLoader => ["liteloader", "lite-loader"],
Versions.Type.OptiFine => ["optifine", "opti-fine"],
_ => []
};

private static bool ContainsIgnoreCase(string value, string expected)
=> value.Contains(expected, StringComparison.OrdinalIgnoreCase);

public async Task<string?> ResolveLaunchVersionAsync(string? preferredVersion = null)
{
EnsureMinecraftBasePathExists();
CreateMCLauncher(_launcherOfflineMode);

var resolvedVersion = _launcherOfflineMode
? await ResolveExistingOfflineVersionAsync(preferredVersion)
: ResolveOnlineLaunchVersion(preferredVersion);

if (!string.IsNullOrWhiteSpace(resolvedVersion))
{
Version.RealVersion = resolvedVersion;
}

return resolvedVersion;
}

public async Task<Process> BuildProcess(
string version,
string? version,
CmlLib.Core.Auth.MSession session,
AccountRuntimeAuthOptions? runtimeAuthOptions = null)
{
_logger.LogInformation("Building process for version: {Version}", version);
CreateMCLauncher(_launcherOfflineMode);
var launchVersion = await ResolveLaunchVersionAsync(version);
if (string.IsNullOrWhiteSpace(launchVersion))
{
throw new InvalidOperationException($"Install or update {Version.DisplayName} before launching.");
}

_logger.LogInformation("Building process for version: {Version}", launchVersion);
var launchOpt = EffectiveSettings.ToMLaunchOption();
launchOpt.Session = session;

Expand All @@ -309,13 +440,30 @@ public async Task<Process> BuildProcess(

_logger.LogDebug(
"Using custom Java runtime for {Version}. JavaPath: {JavaPath}. VersionInfo: {VersionInfo}.",
version,
launchVersion,
validation.NormalizedPath,
validation.Version);
}

_logger.LogDebug("Preparing launch options for {Version}. FullScreen: {FullScreen}. DockName: {DockName}.", version, EffectiveSettings.FullScreen, EffectiveSettings.DockName);
return await Launcher.BuildProcessAsync(version, launchOpt);
_logger.LogDebug("Preparing launch options for {Version}. FullScreen: {FullScreen}. DockName: {DockName}.", launchVersion, EffectiveSettings.FullScreen, EffectiveSettings.DockName);
return await Launcher.BuildProcessAsync(launchVersion, launchOpt);
}

private string? ResolveOnlineLaunchVersion(string? preferredVersion)
{
if (!string.IsNullOrWhiteSpace(preferredVersion))
{
return preferredVersion;
}

if (!string.IsNullOrWhiteSpace(Version.RealVersion))
{
return Version.RealVersion;
}

return Version.Type == Versions.Type.Vanilla && !string.IsNullOrWhiteSpace(Version.BasedOn)
? Version.BasedOn
: null;
}

partial void OnUsesCustomGameSettingsChanged(bool value)
Expand Down
4 changes: 2 additions & 2 deletions Emerald.CoreX/Installers/ModLoaderRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ public ModLoaderRouter()
Installers = Ioc.Default.GetServices<IModLoaderInstaller>();
}

public async Task<string?> RouteAndInitializeAsync(MinecraftPath path, Versions.Version version)
public async Task<string?> RouteAndInitializeAsync(MinecraftPath path, Versions.Version version, bool online = true)
{

if (version.Type == Versions.Type.Vanilla)
return version.BasedOn;

return await Installers.First(x=> x.Type == version.Type).InstallAsync(path, version.BasedOn, version.ModVersion);
return await Installers.First(x=> x.Type == version.Type).InstallAsync(path, version.BasedOn, version.ModVersion, online);
}
}
Loading
Loading