diff --git a/PCL.Core.Test/Minecraft/ResourceProject/ModDependencyResolverTest.cs b/PCL.Core.Test/Minecraft/ResourceProject/ModDependencyResolverTest.cs new file mode 100644 index 000000000..4689052d5 --- /dev/null +++ b/PCL.Core.Test/Minecraft/ResourceProject/ModDependencyResolverTest.cs @@ -0,0 +1,385 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.Minecraft.ResourceProject; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PCL.Core.Test.Minecraft.ResourceProject; + +[TestClass] +public class ModDependencyResolverTest +{ + [TestMethod] + public void ResolvesOneMissingRequiredDependency() + { + var resolver = new ModDependencyResolver(); + var projects = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Modrinth:B"] = new() + { + ProjectId = "B", + Source = "Modrinth", + ProjectName = "Dependency B", + Files = + [ + new ModDependencyFile + { + Id = "b-file", + DisplayName = "Dependency B 1.20.1 Fabric", + Version = "1.0.0", + GameVersions = ["1.20.1"], + Loaders = ["Fabric"], + ReleaseType = 1, + ReleaseDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }, + ], + }, + }; + + var request = new ModDependencyRequest + { + TargetMinecraftVersion = "1.20.1", + TargetLoaders = ["Fabric"], + RequiredDependencies = + [ + new ModDependencyReference + { + ProjectId = "B", + Source = "Modrinth", + }, + ], + ProjectResolver = (source, projectId) => projects.GetValueOrDefault($"{source}:{projectId}"), + }; + + var result = resolver.Resolve(request); + + Assert.AreEqual(1, result.ToInstall.Count); + Assert.AreEqual("B", result.ToInstall[0].ProjectId); + Assert.AreEqual("Modrinth", result.ToInstall[0].Source); + Assert.AreEqual("b-file", result.ToInstall[0].File.Id); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void ReturnsEmptyForNoDependencies() + { + var resolver = new ModDependencyResolver(); + var request = new ModDependencyRequest + { + TargetMinecraftVersion = "1.20.1", + TargetLoaders = ["Fabric"], + ProjectResolver = (_, _) => throw new AssertFailedException("Resolver should not be invoked."), + }; + + var result = resolver.Resolve(request); + + Assert.AreEqual(0, result.ToInstall.Count); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void ResolvesRecursiveDependencies() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile( + "b-file", + requiredDependencies: + [ + CreateDependency("C"), + ])), + CreateProject( + "C", + CreateFile("c-file"))); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B"))); + + CollectionAssert.AreEquivalent(new[] { "B", "C" }, result.ToInstall.Select(static item => item.ProjectId).ToList()); + Assert.AreEqual(2, result.ToInstall.Count); + Assert.IsFalse(result.ToInstall.Any(static item => item.ProjectId == "A")); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void DeduplicatesSharedDependencies() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile( + "b-file", + requiredDependencies: + [ + CreateDependency("D"), + ])), + CreateProject( + "C", + CreateFile( + "c-file", + requiredDependencies: + [ + CreateDependency("D"), + ])), + CreateProject( + "D", + CreateFile("d-file"))); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B"), CreateDependency("C"))); + + Assert.AreEqual(3, result.ToInstall.Count); + CollectionAssert.AreEquivalent(new[] { "B", "C", "D" }, result.ToInstall.Select(static item => item.ProjectId).ToList()); + Assert.AreEqual(1, result.ToInstall.Count(static item => item.ProjectId == "D")); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void TerminatesCycles() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile( + "b-file", + requiredDependencies: + [ + CreateDependency("B"), + ]))); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B"))); + + Assert.AreEqual(1, result.ToInstall.Count); + Assert.AreEqual("B", result.ToInstall[0].ProjectId); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void BlocksUnresolvedRequiredDependency() + { + var resolver = new ModDependencyResolver(); + var projects = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B"))); + + Assert.AreEqual(0, result.ToInstall.Count); + Assert.AreEqual(1, result.Unresolved.Count); + Assert.AreEqual("B", result.Unresolved[0].ProjectId); + StringAssert.Contains(result.Unresolved[0].Reason, "not found"); + } + + [TestMethod] + public void SkipsAlreadyInstalledCompatibleDependency() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile("b-file"))); + + var result = resolver.Resolve(CreateRequest( + projects, + [ + CreateInstalledMod("B"), + ], + CreateDependency("B"))); + + Assert.AreEqual(0, result.ToInstall.Count); + Assert.AreEqual(1, result.Satisfied.Count); + Assert.AreEqual("B", result.Satisfied[0].ProjectId); + StringAssert.Contains(result.Satisfied[0].Reason, "Already installed"); + } + + [TestMethod] + public void TreatsInstalledIncompatibleDependencyAsMissing() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile("b-file"))); + + var result = resolver.Resolve(CreateRequest( + projects, + [ + CreateInstalledMod("B", gameVersions: ["1.19.2"]), + ], + CreateDependency("B"))); + + Assert.AreEqual(1, result.ToInstall.Count); + Assert.AreEqual("B", result.ToInstall[0].ProjectId); + Assert.AreEqual("b-file", result.ToInstall[0].File.Id); + Assert.AreEqual(0, result.Satisfied.Count); + Assert.AreEqual(0, result.Unresolved.Count); + } + + [TestMethod] + public void IgnoresOptionalDependencies() + { + var resolver = new ModDependencyResolver(); + var projects = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B", isRequired: false))); + + Assert.AreEqual(0, result.ToInstall.Count); + Assert.AreEqual(0, result.Unresolved.Count); + Assert.AreEqual(1, result.Satisfied.Count); + Assert.AreEqual("B", result.Satisfied[0].ProjectId); + StringAssert.Contains(result.Satisfied[0].Reason, "Optional dependency ignored"); + } + + [TestMethod] + public void SelectsLatestCompatibleReleaseFile() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + CreateFile( + "b-alpha", + displayName: "Dependency B Alpha", + releaseType: 3, + releaseDate: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)), + CreateFile( + "b-beta", + displayName: "Dependency B Beta", + releaseType: 2, + releaseDate: new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc)), + CreateFile( + "b-release", + displayName: "Dependency B Release", + releaseType: 1, + releaseDate: new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc)))); + + var result = resolver.Resolve(CreateRequest(projects, CreateDependency("B"))); + + Assert.AreEqual(1, result.ToInstall.Count); + Assert.AreEqual("b-release", result.ToInstall[0].File.Id); + Assert.AreEqual(1, result.ToInstall[0].File.ReleaseType); + } + + [TestMethod] + public void DoesNotCrossMatchSources() + { + var resolver = new ModDependencyResolver(); + var projects = CreateProjectStore( + CreateProject( + "B", + [CreateFile("b-file")], + "CurseForge")); + + var result = resolver.Resolve(CreateRequest( + projects, + [ + CreateInstalledMod("B", source: "Modrinth"), + ], + CreateDependency("B", source: "CurseForge"))); + + Assert.AreEqual(1, result.ToInstall.Count); + Assert.AreEqual("B", result.ToInstall[0].ProjectId); + Assert.AreEqual("CurseForge", result.ToInstall[0].Source); + Assert.AreEqual(0, result.Satisfied.Count); + Assert.AreEqual(0, result.Unresolved.Count); + } + + private static ModDependencyRequest CreateRequest( + Dictionary projects, + params ModDependencyReference[] requiredDependencies) + { + return CreateRequest(projects, [], requiredDependencies); + } + + private static ModDependencyRequest CreateRequest( + Dictionary projects, + List installedMods, + params ModDependencyReference[] requiredDependencies) + { + return new ModDependencyRequest + { + TargetMinecraftVersion = "1.20.1", + TargetLoaders = ["Fabric"], + RequiredDependencies = [.. requiredDependencies], + InstalledMods = installedMods, + ProjectResolver = (source, projectId) => projects.GetValueOrDefault($"{source}:{projectId}"), + }; + } + + private static Dictionary CreateProjectStore(params ModDependencyProject[] projects) + { + var store = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var project in projects) + { + store[$"{project.Source}:{project.ProjectId}"] = project; + } + + return store; + } + + private static ModDependencyProject CreateProject(string projectId, params ModDependencyFile[] files) + { + return CreateProject(projectId, files, "Modrinth"); + } + + private static ModDependencyProject CreateProject(string projectId, ModDependencyFile[] files, string source) + { + return new ModDependencyProject + { + ProjectId = projectId, + Source = source, + ProjectName = $"Dependency {projectId}", + Files = [.. files], + }; + } + + private static ModDependencyFile CreateFile( + string id, + string? displayName = null, + string? version = "1.0.0", + List? gameVersions = null, + List? loaders = null, + int releaseType = 1, + DateTime? releaseDate = null, + List? requiredDependencies = null) + { + return new ModDependencyFile + { + Id = id, + DisplayName = displayName ?? id, + Version = version, + GameVersions = gameVersions ?? ["1.20.1"], + Loaders = loaders ?? ["Fabric"], + ReleaseType = releaseType, + ReleaseDate = releaseDate ?? new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + RequiredDependencies = requiredDependencies ?? [], + }; + } + + private static ModDependencyReference CreateDependency(string projectId, string source = "Modrinth", bool isRequired = true) + { + return new ModDependencyReference + { + ProjectId = projectId, + Source = source, + IsRequired = isRequired, + }; + } + + private static InstalledModIdentity CreateInstalledMod( + string projectId, + string source = "Modrinth", + string? modId = null, + List? gameVersions = null, + List? loaders = null) + { + return new InstalledModIdentity + { + SourceProjectId = projectId, + Source = source, + ModId = modId ?? $"mod_{projectId.ToLowerInvariant()}", + GameVersions = gameVersions ?? ["1.20.1"], + Loaders = loaders ?? ["Fabric"], + }; + } +} diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index 1ff4c17ef..d09c3fb1c 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -87,6 +87,7 @@ public static partial class Config [ConfigItem("ToolDownloadTranslate", 0)] public partial int NameFormatV1 { get; set; } [ConfigItem("ToolDownloadTranslateV2", 1)] public partial int NameFormatV2 { get; set; } [ConfigItem("ToolDownloadIgnoreQuilt", false)] public partial bool IgnoreQuilt { get; set; } + [ConfigItem("ToolDownloadAutoInstallDependencies", true)] public partial bool AutoInstallDependencies { get; set; } [ConfigItem("ToolDownloadClipboard", false)] public partial bool ReadClipboard { get; set; } [ConfigItem("ToolDownloadMod", 1)] public partial int CompSourceSolution { get; set; } [ConfigItem("ToolModLocalNameStyle", 0)] public partial int UiCompNameSolution { get; set; } diff --git a/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs b/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs new file mode 100644 index 000000000..46cdf40d0 --- /dev/null +++ b/PCL.Core/Minecraft/ResourceProject/ModDependencyResolver.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PCL.Core.Minecraft.ResourceProject; + +public sealed record class ModDependencyReference +{ + public string ProjectId { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public bool IsRequired { get; init; } = true; +} + +public sealed record class ModDependencyRequest +{ + public string TargetMinecraftVersion { get; init; } = string.Empty; + public List TargetLoaders { get; init; } = []; + public List RequiredDependencies { get; init; } = []; + public List InstalledMods { get; init; } = []; + public Func ProjectResolver { get; init; } = (_, _) => null; +} + +public sealed record class ModDependencyProject +{ + public string ProjectId { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string? ProjectName { get; init; } + public List RequiredDependencies { get; init; } = []; + public List Files { get; init; } = []; +} + +public sealed record class ModDependencyFile +{ + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string? Version { get; init; } + public List GameVersions { get; init; } = []; + public List Loaders { get; init; } = []; + public int ReleaseType { get; init; } + public DateTime ReleaseDate { get; init; } + public List RequiredDependencies { get; init; } = []; +} + +public sealed record class InstalledModIdentity +{ + public string? SourceProjectId { get; init; } + public string? Source { get; init; } + public string? ModId { get; init; } + public List GameVersions { get; init; } = []; + public List Loaders { get; init; } = []; +} + +public sealed record class ModDependencyResolutionResult +{ + public List ToInstall { get; } = []; + public List Unresolved { get; } = []; + public List Satisfied { get; } = []; +} + +public sealed record class ResolvedDependencyInstall +{ + public string ProjectId { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string? ProjectName { get; init; } + public ModDependencyFile File { get; init; } = new(); +} + +public sealed record class UnresolvedDependency +{ + public string ProjectId { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; +} + +public sealed record class IgnoredDependency +{ + public string ProjectId { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; +} + +public sealed class ModDependencyResolver +{ + private const int MaxDepth = 32; + private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + + public ModDependencyResolutionResult Resolve(ModDependencyRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.ProjectResolver); + + var context = new ResolutionContext(request); + foreach (var dependency in request.RequiredDependencies) + { + ResolveDependency(context, dependency, 0); + } + + return context.Result; + } + + private static void ResolveDependency(ResolutionContext context, ModDependencyReference dependency, int depth) + { + if (string.IsNullOrWhiteSpace(dependency.ProjectId) || string.IsNullOrWhiteSpace(dependency.Source)) + { + return; + } + + if (!dependency.IsRequired) + { + context.AddSatisfied(dependency.ProjectId, dependency.Source, "Optional dependency ignored."); + return; + } + + if (depth > MaxDepth) + { + context.AddUnresolved(dependency.ProjectId, dependency.Source, "Maximum dependency depth exceeded."); + return; + } + + var visitedKey = context.GetVisitedKey(dependency.ProjectId, dependency.Source); + if (!context.Visited.Add(visitedKey)) + { + return; + } + + if (context.IsInstalledCompatible(dependency.ProjectId, dependency.Source)) + { + context.AddSatisfied(dependency.ProjectId, dependency.Source, "Already installed and compatible."); + return; + } + + var project = context.Request.ProjectResolver(dependency.Source, dependency.ProjectId); + if (project is null) + { + context.AddUnresolved(dependency.ProjectId, dependency.Source, "Dependency project was not found."); + return; + } + + var selectedFile = SelectBestFile(project.Files, context.TargetMinecraftVersion, context.TargetLoaders); + if (selectedFile is null) + { + context.AddUnresolved(project.ProjectId, project.Source, "No compatible file was found."); + return; + } + + context.AddInstall(project, selectedFile); + + foreach (var nestedDependency in selectedFile.RequiredDependencies) + { + ResolveDependency(context, nestedDependency, depth + 1); + } + } + + private static ModDependencyFile? SelectBestFile( + IEnumerable files, + string targetMinecraftVersion, + HashSet targetLoaders) + { + return files + .Where(file => IsCompatibleFile(file, targetMinecraftVersion, targetLoaders)) + .OrderByDescending(file => HasExactGameVersionMatch(file, targetMinecraftVersion)) + .ThenByDescending(file => HasLoaderMatch(file, targetLoaders)) + .ThenBy(file => NormalizeReleaseType(file.ReleaseType)) + .ThenByDescending(file => file.ReleaseDate) + .FirstOrDefault(); + } + + private static bool IsCompatibleFile(ModDependencyFile file, string targetMinecraftVersion, HashSet targetLoaders) + { + if (!HasExactGameVersionMatch(file, targetMinecraftVersion)) + { + return false; + } + + if (targetLoaders.Count == 0) + { + return true; + } + + if (file.Loaders.Count == 0) + { + return true; + } + + return file.Loaders.Any(loader => targetLoaders.Contains(loader)); + } + + private static bool HasExactGameVersionMatch(ModDependencyFile file, string targetMinecraftVersion) + { + return file.GameVersions.Any(version => Comparer.Equals(version, targetMinecraftVersion)); + } + + private static bool HasLoaderMatch(ModDependencyFile file, HashSet targetLoaders) + { + if (targetLoaders.Count == 0) + { + return true; + } + + return file.Loaders.Any(loader => targetLoaders.Contains(loader)); + } + + private static int NormalizeReleaseType(int releaseType) + { + return releaseType switch + { + 1 => 1, + 2 => 2, + 3 => 3, + _ => int.MaxValue, + }; + } + + private sealed class ResolutionContext + { + private readonly HashSet _installDedupe = new(Comparer); + private readonly HashSet _unresolvedDedupe = new(Comparer); + private readonly HashSet _satisfiedDedupe = new(Comparer); + + public ResolutionContext(ModDependencyRequest request) + { + Request = request; + Result = new ModDependencyResolutionResult(); + Visited = new HashSet(Comparer); + TargetMinecraftVersion = request.TargetMinecraftVersion ?? string.Empty; + TargetLoaders = new HashSet( + request.TargetLoaders.Where(static loader => !string.IsNullOrWhiteSpace(loader)), + Comparer); + LoaderSetKey = string.Join(",", TargetLoaders.OrderBy(static loader => loader, Comparer)); + } + + public ModDependencyRequest Request { get; } + public ModDependencyResolutionResult Result { get; } + public HashSet Visited { get; } + public string TargetMinecraftVersion { get; } + public HashSet TargetLoaders { get; } + private string LoaderSetKey { get; } + + public string GetVisitedKey(string projectId, string source) + { + return $"{source}:{projectId}:{TargetMinecraftVersion}:{LoaderSetKey}"; + } + + public bool IsInstalledCompatible(string projectId, string source) + { + return Request.InstalledMods.Any(installed => + Comparer.Equals(installed.SourceProjectId, projectId) + && Comparer.Equals(installed.Source, source) + && installed.GameVersions.Any(version => Comparer.Equals(version, TargetMinecraftVersion)) + && LoadersCompatible(installed.Loaders)); + } + + public void AddInstall(ModDependencyProject project, ModDependencyFile file) + { + var dedupeKey = GetProjectKey(project.ProjectId, project.Source); + if (!_installDedupe.Add(dedupeKey)) + { + return; + } + + Result.ToInstall.Add(new ResolvedDependencyInstall + { + ProjectId = project.ProjectId, + Source = project.Source, + ProjectName = project.ProjectName, + File = file, + }); + } + + public void AddUnresolved(string projectId, string source, string reason) + { + var dedupeKey = GetProjectKey(projectId, source); + if (!_unresolvedDedupe.Add(dedupeKey)) + { + return; + } + + Result.Unresolved.Add(new UnresolvedDependency + { + ProjectId = projectId, + Source = source, + Reason = reason, + }); + } + + public void AddSatisfied(string projectId, string source, string reason) + { + var dedupeKey = GetProjectKey(projectId, source); + if (!_satisfiedDedupe.Add(dedupeKey)) + { + return; + } + + Result.Satisfied.Add(new IgnoredDependency + { + ProjectId = projectId, + Source = source, + Reason = reason, + }); + } + + private bool LoadersCompatible(List installedLoaders) + { + if (TargetLoaders.Count == 0 || installedLoaders.Count == 0) + { + return true; + } + + return installedLoaders.Any(loader => TargetLoaders.Contains(loader)); + } + + private static string GetProjectKey(string projectId, string source) + { + return $"{source}:{projectId}"; + } + } +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs index bb6215166..cda2beb86 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs @@ -3058,9 +3058,9 @@ public CompFile(JObject Data, CompType DefaultType) if (Data.ContainsKey("Dependencies")) Dependencies = Data["Dependencies"].ToObject>(); if (Data.ContainsKey("RawOptionalDependencies")) - RawDependencies = Data["RawOptionalDependencies"].ToObject>(); + RawOptionalDependencies = Data["RawOptionalDependencies"].ToObject>(); if (Data.ContainsKey("OptionalDependencies")) - Dependencies = Data["OptionalDependencies"].ToObject>(); + OptionalDependencies = Data["OptionalDependencies"].ToObject>(); } #endregion diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModCompDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModCompDependency.cs new file mode 100644 index 000000000..5431a55f3 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModCompDependency.cs @@ -0,0 +1,296 @@ +using System.IO; +using CompFile = PCL.ModComp.CompFile; +using CompFileStatus = PCL.ModComp.CompFileStatus; +using CompLoaderType = PCL.ModComp.CompLoaderType; +using CompProject = PCL.ModComp.CompProject; +using LocalCompFile = PCL.ModLocalComp.LocalCompFile; +using PCL.Core.Minecraft.ResourceProject; +using PCL.Network; + +namespace PCL; + +public static class ModCompDependency +{ + public static ModDependencyRequest BuildRequest( + CompFile file, + CompProject project, + string targetMinecraftVersion, + List targetLoaders, + string targetModsFolder) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentNullException.ThrowIfNull(project); + targetLoaders ??= new List(); + + var source = GetSource(project.FromCurseForge); + var dependencies = file.Dependencies + .Where(static dependencyId => !string.IsNullOrWhiteSpace(dependencyId)) + .Select(dependencyId => new ModDependencyReference + { + ProjectId = dependencyId, + Source = source, + IsRequired = true, + }) + .Concat(file.OptionalDependencies + .Where(static dependencyId => !string.IsNullOrWhiteSpace(dependencyId)) + .Select(dependencyId => new ModDependencyReference + { + ProjectId = dependencyId, + Source = source, + IsRequired = false, + })) + .ToList(); + + return new ModDependencyRequest + { + TargetMinecraftVersion = targetMinecraftVersion ?? string.Empty, + TargetLoaders = ToLoaderNames(targetLoaders), + RequiredDependencies = dependencies, + InstalledMods = ScanInstalledMods(targetModsFolder), + ProjectResolver = ResolveProjectFiles, + }; + } + + public static List ScanInstalledMods(string targetModsFolder) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(targetModsFolder) || !Directory.Exists(targetModsFolder)) + { + return result; + } + + foreach (var path in Directory.GetFiles(targetModsFolder)) + { + if (!LocalCompFile.IsModFile(path)) + { + continue; + } + + var localFile = new LocalCompFile(path); + localFile.Load(); + + var source = localFile.Comp is null ? null : GetSource(localFile.Comp.FromCurseForge); + var gameVersions = localFile.CompFile?.GameVersions?.Where(static version => !string.IsNullOrWhiteSpace(version)).ToList() + ?? new List(); + var loaders = ToLoaderNames(localFile.CompFile?.ModLoaders); + + if (!string.IsNullOrWhiteSpace(localFile.Comp?.Id) && !string.IsNullOrWhiteSpace(source)) + { + result.Add(new InstalledModIdentity + { + SourceProjectId = localFile.Comp.Id, + Source = source, + ModId = localFile.ModId, + GameVersions = gameVersions, + Loaders = loaders, + }); + continue; + } + + if (!string.IsNullOrWhiteSpace(localFile.CompFile?.ProjectId)) + { + var fileSource = GetSource(localFile.CompFile.FromCurseForge); + result.Add(new InstalledModIdentity + { + SourceProjectId = localFile.CompFile.ProjectId, + Source = fileSource, + ModId = localFile.ModId, + GameVersions = gameVersions, + Loaders = loaders, + }); + continue; + } + + result.Add(new InstalledModIdentity + { + SourceProjectId = null, + Source = null, + ModId = localFile.ModId, + GameVersions = gameVersions, + Loaders = loaders, + }); + } + + return result; + } + + public static ModDependencyProject? ResolveProjectFiles(string source, string projectId) + { + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(projectId)) + { + return null; + } + + var fromCurseForge = string.Equals(source, "CurseForge", StringComparison.OrdinalIgnoreCase); + var files = ModComp.CompFilesGet(projectId, fromCurseForge); + if (!ModComp.CompProjectCache.TryGetValue(projectId, out var compProject)) + { + return null; + } + + if (compProject.FromCurseForge != fromCurseForge) + { + return null; + } + + return new ModDependencyProject + { + ProjectId = compProject.Id, + Source = source, + ProjectName = compProject.TranslatedName ?? compProject.RawName, + RequiredDependencies = files + .SelectMany(static compFile => compFile.Dependencies) + .Where(static dependencyId => !string.IsNullOrWhiteSpace(dependencyId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(dependencyId => new ModDependencyReference + { + ProjectId = dependencyId, + Source = source, + IsRequired = true, + }) + .ToList(), + Files = files.Select(compFile => new ModDependencyFile + { + Id = compFile.Id, + DisplayName = compFile.DisplayName, + Version = compFile.Version, + GameVersions = compFile.GameVersions?.Where(static version => !string.IsNullOrWhiteSpace(version)).ToList() + ?? new List(), + Loaders = ToLoaderNames(compFile.ModLoaders), + ReleaseType = MapReleaseType(compFile.Status), + ReleaseDate = compFile.ReleaseDate, + RequiredDependencies = compFile.Dependencies + .Where(static dependencyId => !string.IsNullOrWhiteSpace(dependencyId)) + .Select(dependencyId => new ModDependencyReference + { + ProjectId = dependencyId, + Source = source, + IsRequired = true, + }) + .ToList(), + }).ToList(), + }; + } + + public static ModDependencyFile? SelectCompatibleDependencyFile( + ModDependencyResolutionResult result, + string projectId, + string source) + { + ArgumentNullException.ThrowIfNull(result); + + return result.ToInstall + .FirstOrDefault(install => + string.Equals(install.ProjectId, projectId, StringComparison.OrdinalIgnoreCase) + && string.Equals(install.Source, source, StringComparison.OrdinalIgnoreCase)) + ?.File; + } + + public static List BuildDependencyDownloads( + ModDependencyResolutionResult result, + string targetModsFolder) + { + ArgumentNullException.ThrowIfNull(result); + + var downloads = new List(); + foreach (var install in result.ToInstall.AsEnumerable().Reverse()) + { + if (!ModComp.CompProjectCache.TryGetValue(install.ProjectId, out var depProject)) + { + continue; + } + + var fromCurseForge = string.Equals(install.Source, "CurseForge", StringComparison.OrdinalIgnoreCase); + if (depProject.FromCurseForge != fromCurseForge) + { + continue; + } + + var depCompFile = ModComp.CompFilesGet(install.ProjectId, fromCurseForge) + .FirstOrDefault(file => string.Equals(file.Id, install.File.Id, StringComparison.OrdinalIgnoreCase)); + if (depCompFile is null) + { + continue; + } + + var targetPath = Path.Combine(targetModsFolder ?? string.Empty, ModComp.CompFileNameGet(depProject, depCompFile)); + downloads.Add(depCompFile.ToNetFile(targetPath)); + } + + return downloads; + } + + /// + /// Shows confirmation dialog for required dependency installs. + /// Returns true if user confirms, false if user cancels or there are unresolved required deps. + /// + public static bool ConfirmDependencyInstall(ModDependencyResolutionResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.Unresolved is { Count: > 0 }) + { + ModBase.Log($"[CompDeps] 无法解析: {result.Unresolved.Count} 个必需前置"); + var message = "以下必需前置无法解析:\n\n" + + string.Join("\n", result.Unresolved + .Select(dep => $"- {dep.Source} {dep.ProjectId}: {dep.Reason}")); + ModMain.MyMsgBox(message, "无法安装必需前置", Button1: "确定", IsWarn: true, ForceWait: true); + return false; + } + + if (result.ToInstall is { Count: > 0 }) + { + var message = "此 Mod 需要以下必需前置:\n\n" + + string.Join("\n", result.ToInstall + .Select(install => + $"- {install.ProjectName} ({install.Source}) - {install.File.DisplayName} v{install.File.Version}")); + var dialogResult = ModMain.MyMsgBox(message, "安装 Mod 前置确认", + Button1: "安装 Mod 与必需前置", Button2: "取消安装", ForceWait: true); + if (dialogResult != 1) + { + ModBase.Log("[CompDeps] 用户取消,已中止安装"); + } + return dialogResult == 1; + } + + return true; + } + + /// + /// Shows abort message when dependency resolution was cancelled by user or failed. + /// + public static void ShowDependencyAbortMessage(string reason) + { + ModMain.MyMsgBox(reason, "安装已中止", Button1: "确定", IsWarn: false, ForceWait: true); + } + + private static string GetSource(bool fromCurseForge) + { + return fromCurseForge ? "CurseForge" : "Modrinth"; + } + + private static List ToLoaderNames(IEnumerable? loaders) + { + if (loaders is null) + { + return new List(); + } + + return loaders + .Where(static loader => loader != CompLoaderType.Any) + .Select(static loader => loader.ToString()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static int MapReleaseType(CompFileStatus status) + { + return status switch + { + CompFileStatus.Release => 1, + CompFileStatus.Beta => 2, + CompFileStatus.Alpha => 3, + _ => 1, + }; + } +} diff --git a/Plain Craft Launcher 2/Pages/PageDownload/Comp/PageDownloadCompDetail.xaml.cs b/Plain Craft Launcher 2/Pages/PageDownload/Comp/PageDownloadCompDetail.xaml.cs index daf4861b9..ff51ba0aa 100644 --- a/Plain Craft Launcher 2/Pages/PageDownload/Comp/PageDownloadCompDetail.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageDownload/Comp/PageDownloadCompDetail.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Controls; using FluentValidation; using PCL.Core.App; +using PCL.Core.Minecraft.ResourceProject; using PCL.Core.UI; using PCL.Core.Utils.Validate; using PCL.Network; @@ -267,6 +268,7 @@ public void Save_Click(object sender, EventArgs e) // 确认默认保存位置 string DefaultFolder = null; + var AllowedLoaders = new List(); if (File.Type != ModComp.CompType.ModPack) { var SubFolder = ""; @@ -280,7 +282,6 @@ public void Save_Click(object sender, EventArgs e) } // 获取资源所需的加载器 - var AllowedLoaders = new List(); if (File.ModLoaders.Any()) AllowedLoaders = File.ModLoaders; else if (_project.ModLoaders.Any()) AllowedLoaders = _project.ModLoaders; @@ -377,21 +378,97 @@ public void Save_Click(object sender, EventArgs e) if (!Target.Contains("\\")) return; - // 记录缓存路径 - var targetDir = ModBase.GetPathFromFullPath(Target); - if (Target != DefaultFolder) + // 记录缓存路径 + var targetDir = ModBase.GetPathFromFullPath(Target); + if (Target != DefaultFolder) { if (CachedFolder.ContainsKey(File.Type)) CachedFolder[File.Type] = targetDir; else - CachedFolder.Add(File.Type, targetDir); - } - - // 构造下载任务 - var LoaderName = $"{Desc}下载:{ModBase.GetFileNameWithoutExtentionFromPath(Target)} "; - var Loaders = new List - { - new LoaderDownload("下载文件", new List { File.ToNetFile(Target) }) + CachedFolder.Add(File.Type, targetDir); + } + + var downloadFiles = new List { File.ToNetFile(Target) }; + if (File.Type == ModComp.CompType.Mod && Config.Download.Comp.AutoInstallDependencies && + File.Dependencies.Any()) + { + try + { + ModMinecraft.McInstance? targetInstance = null; + var knownInstances = new List(); + if (ModMinecraft.McInstanceSelected is not null) + { + knownInstances.Add(ModMinecraft.McInstanceSelected); + } + + knownInstances.AddRange(ModMinecraft.McInstanceList.Values.SelectMany(list => list) + .Where(instance => instance is not null)); + targetInstance = knownInstances + .Distinct() + .FirstOrDefault(instance => + targetDir.StartsWith(instance.PathIndie, StringComparison.OrdinalIgnoreCase)); + if (targetInstance is not null && !targetInstance.IsLoaded) + { + targetInstance.Load(); + } + + var mcVersion = targetInstance?.Info?.VanillaName + ?? File.GameVersions.FirstOrDefault(version => version.Contains(".")) + ?? string.Empty; + var targetLoaders = new List(); + if (targetInstance is not null) + { + if (targetInstance.Info.HasForge) + targetLoaders.Add(ModComp.CompLoaderType.Forge); + if (targetInstance.Info.HasFabric || targetInstance.Info.HasLegacyFabric) + targetLoaders.Add(ModComp.CompLoaderType.Fabric); + if (targetInstance.Info.HasQuilt) + targetLoaders.Add(ModComp.CompLoaderType.Quilt); + if (targetInstance.Info.HasNeoForge) + targetLoaders.Add(ModComp.CompLoaderType.NeoForge); + if (targetInstance.Info.HasLiteLoader) + targetLoaders.Add(ModComp.CompLoaderType.LiteLoader); + } + + if (!targetLoaders.Any()) + { + targetLoaders = AllowedLoaders.ToList(); + } + + ModBase.Log($"[CompDeps] 开始解析必需前置: {File.Dependencies.Count} 个依赖"); + var request = ModCompDependency.BuildRequest(File, _project, mcVersion, targetLoaders, + targetDir); + var resolver = new ModDependencyResolver(); + var result = resolver.Resolve(request); + + if (result.Unresolved.Any() || result.ToInstall.Any()) + { + if (!ModCompDependency.ConfirmDependencyInstall(result)) + { + return; + } + + ModBase.Log($"[CompDeps] 准备下载: {result.ToInstall.Count} 个前置"); + var depDownloads = ModCompDependency.BuildDependencyDownloads(result, targetDir); + downloadFiles = depDownloads.Concat(downloadFiles).ToList(); + } + else + { + ModBase.Log("[CompDeps] 已满足: 所有必需前置已安装"); + } + } + catch (Exception depEx) + { + ModBase.Log(depEx, "[CompDeps] 依赖解析失败"); + return; + } + } + + // 构造下载任务 + var LoaderName = $"{Desc}下载:{ModBase.GetFileNameWithoutExtentionFromPath(Target)} "; + var Loaders = new List + { + new LoaderDownload("下载文件", downloadFiles) { ProgressWeight = 6, Block = true diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index 82a8b2ac6..2880122cc 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -119,9 +119,16 @@ + + + + + diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs index 3bbf61f73..bdc30fcc0 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs @@ -45,6 +45,7 @@ public void Reload() ComboDownloadMod.SelectedIndex = Config.Download.Comp.CompSourceSolution; ComboModLocalNameStyle.SelectedIndex = Config.Download.Comp.UiCompNameSolution; CheckDownloadIgnoreQuilt.Checked = (bool?)Config.Download.Comp.IgnoreQuilt; + CheckDownloadAutoInstallDependencies.Checked = (bool?)Config.Download.Comp.AutoInstallDependencies; CheckDownloadClipboard.Checked = (bool?)Config.Download.Comp.ReadClipboard; // Minecraft 更新提示