diff --git a/Directory.Packages.props b/Directory.Packages.props index ae23f6930c..f8c1e87082 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,9 @@ + + + diff --git a/NexusMods.App.sln b/NexusMods.App.sln index 070efeea92..bd64cb550a 100644 --- a/NexusMods.App.sln +++ b/NexusMods.App.sln @@ -111,6 +111,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Common.Tests", "tests\NexusMods.Common.Tests\NexusMods.Common.Tests.csproj", "{FE0B804A-949E-44E7-9531-B16664ACEC01}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.MountAndBlade2Bannerlord", "src\Games\NexusMods.Games.MountAndBlade2Bannerlord\NexusMods.Games.MountAndBlade2Bannerlord.csproj", "{3E970563-DAE0-4168-AE8D-AB09A786C8A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.MountAndBlade2Bannerlord.Tests", "tests\Games\NexusMods.Games.MountAndBlade2Bannerlord.Tests\NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj", "{355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.NexusWebApi.NMA", "src\Networking\NexusMods.Networking.NexusWebApi.NMA\NexusMods.Networking.NexusWebApi.NMA.csproj", "{871E2565-BD95-43D1-9EC3-CAAC74D55507}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Downloaders", "src\Networking\NexusMods.Networking.Downloaders\NexusMods.Networking.Downloaders.csproj", "{3FBDEE15-9892-40EF-9593-6353068FAF48}" @@ -285,6 +289,14 @@ Global {FE0B804A-949E-44E7-9531-B16664ACEC01}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE0B804A-949E-44E7-9531-B16664ACEC01}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE0B804A-949E-44E7-9531-B16664ACEC01}.Release|Any CPU.Build.0 = Release|Any CPU + {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E970563-DAE0-4168-AE8D-AB09A786C8A3}.Release|Any CPU.Build.0 = Release|Any CPU + {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA}.Release|Any CPU.Build.0 = Release|Any CPU {871E2565-BD95-43D1-9EC3-CAAC74D55507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {871E2565-BD95-43D1-9EC3-CAAC74D55507}.Debug|Any CPU.Build.0 = Debug|Any CPU {871E2565-BD95-43D1-9EC3-CAAC74D55507}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -372,6 +384,8 @@ Global {83B2A024-0218-4F65-B75B-0102DAF38443} = {02A589BE-50CA-4D29-BA99-81EEA2410F8D} {CB61A764-B3BB-42C0-8CDB-DBE57FB80DF5} = {CF7454A5-0EBB-46E7-9A10-614380DB95D9} {FE0B804A-949E-44E7-9531-B16664ACEC01} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B} + {3E970563-DAE0-4168-AE8D-AB09A786C8A3} = {70D38D24-79AE-4600-8E83-17F3C11BA81F} + {355C8D44-F46F-4AA2-96C0-DDB6844D8BEA} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D} {871E2565-BD95-43D1-9EC3-CAAC74D55507} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C} {3FBDEE15-9892-40EF-9593-6353068FAF48} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C} {09B037AB-07BB-4154-95FD-6EA2E55C4568} = {897C4198-884F-448A-B0B0-C2A6D971EAE0} diff --git a/docs/games/000X-Bannerlord.md b/docs/games/000X-Bannerlord.md new file mode 100644 index 0000000000..24baabc46d --- /dev/null +++ b/docs/games/000X-Bannerlord.md @@ -0,0 +1,33 @@ +## General Info + +- Name: Mount & Blade II: Bannerlord +- Release Date: 2020 +- Engine: Custom - C++ Foundation, C# Scripting + +### Stores and Ids + +- [Steam](https://store.steampowered.com/app/261550/Mount__Blade_II_Bannerlord/): `261550` +- [GOG](https://www.gog.com/game/mount_blade_ii_bannerlord): `1802539526`, `1564781494` +- [Epic Game Store](https://store.epicgames.com/en-US/p/mount-and-blade-2): `Chickadee` +- [Xbox Game Pass](https://www.xbox.com/en-US/games/store/mount-blade-ii-bannerlord/9pdhwz7x3p03): `TaleWorldsEntertainment.MountBladeIIBannerlord` + +### Engine and Mod Support + +Bannerlord uses .NET Framework 4.7.2 for Steam/GOG/Epic and .NET Core 3.1 for Xbox game Pass PC. +Modding is supported out of the box. +Bannerlord has a modding extension [BLSE](https://www.nexusmods.com/mountandblade2bannerlord/mods/1) that expands the modding capabilities. +It's required to run mods on Xbox and is optional for Steam/GOG/Epic. + +## Overview of Mod loading process(es) + +## Uploaded Files Structure + +## Additional Considerations for Manager + +## Essential Mods & Tools + +## Deployment Strategy + +## Work To Do + +## Misc Notes diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig new file mode 100644 index 0000000000..ece3986bd3 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] +max_line_length = 180 + +space_within_single_line_array_initializer_braces = true \ No newline at end of file diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs new file mode 100644 index 0000000000..df0327ce42 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Emitters/BuiltInEmitter.cs @@ -0,0 +1,103 @@ +using Bannerlord.LauncherManager; +using Bannerlord.LauncherManager.Localization; +using Bannerlord.ModuleManager; +using NexusMods.DataModel.Diagnostics; +using NexusMods.DataModel.Diagnostics.Emitters; +using NexusMods.DataModel.Diagnostics.References; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.Games.MountAndBlade2Bannerlord.Extensions; +using NexusMods.Games.MountAndBlade2Bannerlord.Utils; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Emitters; + +public class BuiltInEmitter : ILoadoutDiagnosticEmitter +{ + internal const string Source = "NexusMods.Games.MountAndBlade2Bannerlord"; + + public async IAsyncEnumerable Diagnose(Loadout loadout) + { + await Task.Yield(); + + var viewModels = (await loadout.GetSortedViewModelsAsync()).ToList(); + var lookup = viewModels.ToDictionary(x => x.ModuleInfoExtended.Id, x => x); + var modules = lookup.Values.Select(x => x.ModuleInfoExtended).Concat(FeatureIds.LauncherFeatures.Select(x => new ModuleInfoExtended { Id = x })).ToList(); + + var ctx = new ModuleContext(lookup); + foreach (var moduleViewModel in viewModels) + { + foreach (var diagnostic in ModuleUtilities.ValidateModule(modules, moduleViewModel.ModuleInfoExtended, ctx.GetIsSelected, ctx.GetIsValid).Select(x => Render(loadout, moduleViewModel.Mod, x))) + { + yield return diagnostic; + } + } + } + + private static Diagnostic Render(Loadout loadout, Mod mod, ModuleIssue issue) + { + static string Version(ApplicationVersionRange version) => version == ApplicationVersionRange.Empty + ? version.ToString() + : version.Min == version.Max + ? version.Min.ToString() + : ""; + + // We reuse the translation for now + var (level, message) = issue.Type switch + { + ModuleIssueType.Missing => (DiagnosticSeverity.Critical, new BUTRTextObject("{=J3Uh6MV4}Missing '{ID}' {VERSION} in modules list") + .SetTextVariable("ID", issue.SourceId) + .SetTextVariable("VERSION", issue.SourceVersion.Min.ToString())), + + ModuleIssueType.MissingDependencies => (DiagnosticSeverity.Critical, new BUTRTextObject("{=3eQSr6wt}Missing '{ID}' {VERSION}") + .SetTextVariable("ID", issue.SourceId) + .SetTextVariable("VERSION", Version(issue.SourceVersion))), + ModuleIssueType.DependencyMissingDependencies => (DiagnosticSeverity.Critical, new BUTRTextObject("{=U858vdQX}'{ID}' is missing it's dependencies!") + .SetTextVariable("ID", issue.SourceId)), + + ModuleIssueType.DependencyValidationError => (DiagnosticSeverity.Critical, new BUTRTextObject("{=1LS8Z5DU}'{ID}' has unresolved issues!") + .SetTextVariable("ID", issue.SourceId)), + + ModuleIssueType.VersionMismatchLessThanOrEqual => (DiagnosticSeverity.Warning, new BUTRTextObject("{=Vjz9HQ41}'{ID}' wrong version <= {VERSION}") + .SetTextVariable("ID", issue.SourceId) + .SetTextVariable("VERSION", Version(issue.SourceVersion))), + ModuleIssueType.VersionMismatchLessThan => (DiagnosticSeverity.Warning, new BUTRTextObject("{=ZvnlL7VE}'{ID}' wrong version < [{VERSION}]") + .SetTextVariable("ID", issue.SourceId) + .SetTextVariable("VERSION", Version(issue.SourceVersion))), + ModuleIssueType.VersionMismatchGreaterThan => (DiagnosticSeverity.Warning, new BUTRTextObject("{=EfNuH2bG}'{ID}' wrong version > [{VERSION}]") + .SetTextVariable("ID", issue.SourceId) + .SetTextVariable("VERSION", Version(issue.SourceVersion))), + + ModuleIssueType.Incompatible => (DiagnosticSeverity.Warning, new BUTRTextObject("{=zXDidmpQ}'{ID}' is incompatible with this module") + .SetTextVariable("ID", issue.SourceId)), + + ModuleIssueType.DependencyConflictDependentAndIncompatible => (DiagnosticSeverity.Critical, new BUTRTextObject("{=4KFwqKgG}Module '{ID}' is both depended upon and marked as incompatible") + .SetTextVariable("ID", issue.SourceId)), + ModuleIssueType.DependencyConflictDependentLoadBeforeAndAfter => (DiagnosticSeverity.Critical, new BUTRTextObject("{=9DRB6yXv}Module '{ID}' is both depended upon as LoadBefore and LoadAfter") + .SetTextVariable("ID", issue.SourceId)), + ModuleIssueType.DependencyConflictCircular => (DiagnosticSeverity.Critical, new BUTRTextObject("{=RC1V9BbP}Circular dependencies. '{TARGETID}' and '{SOURCEID}' depend on each other") + .SetTextVariable("TARGETID", issue.Target.Id) + .SetTextVariable("SOURCEID", issue.SourceId)), + + ModuleIssueType.DependencyNotLoadedBeforeThis => (DiagnosticSeverity.Warning, new BUTRTextObject("{=s3xbuejE}'{SOURCEID}' should be loaded before '{TARGETID}'") + .SetTextVariable("TARGETID", issue.Target.Id) + .SetTextVariable("SOURCEID", issue.SourceId)), + + ModuleIssueType.DependencyNotLoadedAfterThis => (DiagnosticSeverity.Warning, new BUTRTextObject("{=2ALJB7z2}'{SOURCEID}' should be loaded after '{TARGETID}'") + .SetTextVariable("ID", issue.SourceId)), + + _ => throw new ArgumentOutOfRangeException(nameof(issue)) + }; + + return new Diagnostic + { + Id = new DiagnosticId(Source, (ushort) issue.Type), + Message = DiagnosticMessage.From(message.ToString()), + Severity = level, + DataReferences = new IDataReference[] + { + loadout.ToReference(), + mod.ToReference(loadout) + } + }; + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs new file mode 100644 index 0000000000..f340f43de6 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/LoadoutExtensions.cs @@ -0,0 +1,64 @@ +using Bannerlord.LauncherManager; +using Bannerlord.LauncherManager.Models; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.ModFiles; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Extensions; + +internal delegate LoadoutModuleViewModel ViewModelCreator(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index); + +internal static class LoadoutExtensions +{ + private static LoadoutModuleViewModel Default(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index) => new() + { + Mod = mod, + ModuleInfoExtended = moduleInfo, + IsValid = mod.GetSubModuleFileMetadata()?.IsValid == true, + IsSelected = mod.Enabled, + IsDisabled = mod.Status == ModStatus.Failed, + Index = index, + }; + + private static async Task> SortMods(Loadout loadout) + { + var loadoutSynchronizer = (loadout.Installation.Game.Synchronizer as MountAndBlade2BannerlordLoadoutSynchronizer)!; + + var sorted = await loadoutSynchronizer.SortMods(loadout); + return sorted; + } + + public static IEnumerable GetViewModels(this Loadout loadout, IEnumerable mods, ViewModelCreator? viewModelCreator = null) + { + viewModelCreator ??= Default; + var i = 0; + return mods.Select(x => + { + var moduleInfo = x.GetModuleInfo(); + if (moduleInfo is null) return null; + + var subModule = x.Files.Values.OfType().First(y => y.To.FileName.Path.Equals(Constants.SubModuleName, StringComparison.OrdinalIgnoreCase)); + var subModulePath = loadout.Installation.LocationsRegister.GetResolvedPath(subModule.To).GetFullPath(); + + return viewModelCreator(x, new ModuleInfoExtendedWithPath(moduleInfo, subModulePath), i++); + }).OfType(); + } + + public static async Task> GetSortedViewModelsAsync(this Loadout loadout, ViewModelCreator? viewModelCreator = null) + { + var sortedMods = await SortMods(loadout); + return GetViewModels(loadout, sortedMods, viewModelCreator); + } + + public static IEnumerable GetViewModels(this Loadout loadout, ViewModelCreator? viewModelCreator = null) + { + return GetViewModels(loadout, loadout.Mods.Values, viewModelCreator); + } + + public static bool HasModuleInstalled(this Loadout loadout, string moduleId) => loadout.Mods.Values.Any(x => + x.GetModuleInfo() is { } moduleInfo && moduleInfo.Id.Equals(moduleId, StringComparison.OrdinalIgnoreCase)); + + public static bool HasInstalledFile(this Loadout loadout, string filename) => loadout.Mods.Values.Any(x => + x.GetModuleFileMetadatas().Any(y => y.OriginalRelativePath.EndsWith(filename, StringComparison.OrdinalIgnoreCase))); +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs new file mode 100644 index 0000000000..85dba3e554 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Extensions/ModExtensions.cs @@ -0,0 +1,16 @@ +using Bannerlord.ModuleManager; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Extensions; + +internal static class ModExtensions +{ + public static SubModuleFileMetadata? GetSubModuleFileMetadata(this Mod mod) => mod.Files.SelectMany(y => y.Value.Metadata).OfType().FirstOrDefault(); + public static ModuleInfoExtended? GetModuleInfo(this Mod mod) => GetSubModuleFileMetadata(mod)?.ModuleInfo; + + public static IEnumerable GetModuleFileMetadatas(this Mod mod) => mod.Files.Values.Select(GetModuleFileMetadata).OfType(); + public static ModuleFileMetadata? GetModuleFileMetadata(this AModFile modFile) => modFile.Metadata.OfType().FirstOrDefault(); + public static string? GetOriginalRelativePath(this AModFile mod) => GetModuleFileMetadata(mod)?.OriginalRelativePath; +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs new file mode 100644 index 0000000000..1e53cd08c8 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Installers/MountAndBlade2BannerlordModInstaller.cs @@ -0,0 +1,129 @@ +using System.Xml; +using Bannerlord.LauncherManager.Models; +using Bannerlord.ModuleManager; +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Common; +using NexusMods.DataModel.Abstractions; +using NexusMods.DataModel.Games; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.ModInstallers; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; +using NexusMods.Games.MountAndBlade2Bannerlord.Services; +using NexusMods.Games.MountAndBlade2Bannerlord.Sorters; +using NexusMods.Paths; +using NexusMods.Paths.Extensions; +using NexusMods.Paths.FileTree; +using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Installers; + +public sealed class MountAndBlade2BannerlordModInstaller : AModInstaller +{ + private readonly LauncherManagerFactory _launcherManagerFactory; + + private MountAndBlade2BannerlordModInstaller(IServiceProvider serviceProvider) : base(serviceProvider) + { + _launcherManagerFactory = serviceProvider.GetRequiredService(); + } + + public static MountAndBlade2BannerlordModInstaller Create(IServiceProvider serviceProvider) => new(serviceProvider); + + private static IAsyncEnumerable<(FileTreeNode ModuleInfoFile, ModuleInfoExtended ModuleInfo)> GetModuleInfoFiles( + FileTreeNode files) + { + return files.GetAllDescendentFiles().SelectAsync(async kv => + { + var (path, file) = kv; + + if (!path.FileName.Equals(SubModuleFile)) + return default; + + await using var stream = await file!.Open(); + try + { + var doc = new XmlDocument(); + doc.Load(stream); + var data = ModuleInfoExtended.FromXml(doc); + return (ModuleInfoFile: kv, ModuleInfo: data); + } + catch (Exception e) + { + return default; + //_logger.LogError("Failed to Parse Bannerlord Module: {EMessage}\\n{EStackTrace}", e.Message, e.StackTrace); + } + }).Where(kv => kv.ModuleInfo != null!); + } + + public override async ValueTask> GetModsAsync(GameInstallation installation, LoadoutId loadoutId, ModId baseModId, + FileTreeNode archiveFiles, CancellationToken ct = default) + { + var moduleInfoFiles = await GetModuleInfoFiles(archiveFiles).ToArrayAsync(ct); + + // Not a valid Bannerlord Module - install in root folder the content + if (!moduleInfoFiles.Any()) + { + //return NoResults; + + var modFiles = archiveFiles.GetAllDescendentFiles().Select(kv => + { + var (path, file) = kv; + var moduleRoot = path.Parent; + + return file!.ToStoredFile(new GamePath(LocationId.Game, ModFolder.Join(path.DropFirst(moduleRoot.Depth - 1)))); + }); + return new List + { + new ModInstallerResult + { + Id = baseModId, + Files = modFiles + } + }; + } + + var launcherManager = _launcherManagerFactory.Get(installation); + + return moduleInfoFiles.Select(node => + { + var (moduleInfoFile, moduleInfo) = node; + var moduleRoot = moduleInfoFile.Parent; + var moduleInfoWithPath = new ModuleInfoExtendedWithPath(moduleInfo, moduleInfoFile.Path); + + // InstallModuleContent will only install mods if the ModuleInfoExtendedWithPath for a mod was provided + var result = launcherManager.InstallModuleContent(moduleRoot.GetAllDescendentFiles().Select(x => x.Path.ToString()).ToArray(), new[] { moduleInfoWithPath }); + var modFiles = result.Instructions.OfType().Select(instruction => + { + static IEnumerable GetMetadata(ModuleInfoExtendedWithPath moduleInfo, RelativePath relativePath) + { + yield return new ModuleFileMetadata { OriginalRelativePath = relativePath.Path }; + if (relativePath.Equals(SubModuleFile)) yield return new SubModuleFileMetadata + { + IsValid = true, // TODO: For now lets keep it true while we don't have the validation layer + ModuleInfo = moduleInfo + }; + } + + var relativePath = instruction.Source.ToRelativePath(); + var (path, file) = moduleRoot.FindNode(relativePath)!; + + var fromArchive = file!.ToStoredFile(new GamePath(LocationId.Game, ModFolder.Join(path.DropFirst(moduleRoot.Depth - 1)))); + return fromArchive with + { + Metadata = fromArchive.Metadata.AddRange(GetMetadata(moduleInfoWithPath, relativePath)) + }; + }); + + return new ModInstallerResult + { + Id = ModId.NewId(), + Files = modFiles, + Name = moduleInfo.Name, + Version = moduleInfo.Version.ToString(), + SortRules = new [] + { + new ModuleInfoSort() + }, + }; + }); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs new file mode 100644 index 0000000000..ac625ca8ca --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/LoadoutModuleViewModel.cs @@ -0,0 +1,19 @@ +using Bannerlord.LauncherManager.Models; +using NexusMods.DataModel.Loadouts.Mods; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Models; + +internal class LoadoutModuleViewModel : IModuleViewModel +{ + public required Mod Mod { get; init; } + + public required ModuleInfoExtendedWithPath ModuleInfoExtended { get; init; } + + public required bool IsValid { get; init; } + + public required bool IsSelected { get; set; } + + public required bool IsDisabled { get; set; } + + public required int Index { get; set; } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs new file mode 100644 index 0000000000..d6e651e109 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/ModuleFileMetadata.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using NexusMods.DataModel.Abstractions; +using NexusMods.DataModel.JsonConverters; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Models; + +[PublicAPI] +[JsonName("NexusMods.Games.MountAndBlade2Bannerlord.ModuleFileMetadata")] +public class ModuleFileMetadata : IMetadata +{ + public required string OriginalRelativePath { get; init; } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs new file mode 100644 index 0000000000..44344582fd --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Models/SubModuleFileMetadata.cs @@ -0,0 +1,14 @@ +using Bannerlord.ModuleManager; +using JetBrains.Annotations; +using NexusMods.DataModel.Abstractions; +using NexusMods.DataModel.JsonConverters; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Models; + +[PublicAPI] +[JsonName("NexusMods.Games.MountAndBlade2Bannerlord.SubModuleFileMetadata")] +public class SubModuleFileMetadata : IMetadata +{ + public bool IsValid { get; set; } // TODO: I guess this is where we will store the validation check result? + public required ModuleInfoExtended ModuleInfo { get; init; } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs new file mode 100644 index 0000000000..059b6d1851 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2Bannerlord.cs @@ -0,0 +1,78 @@ +using NexusMods.Common; +using NexusMods.DataModel.Games; +using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability; +using NexusMods.DataModel.LoadoutSynchronizer; +using NexusMods.DataModel.ModInstallers; +using NexusMods.FileExtractor.StreamFactories; +using NexusMods.Games.MountAndBlade2Bannerlord.Installers; +using NexusMods.Games.MountAndBlade2Bannerlord.Services; +using NexusMods.Games.MountAndBlade2Bannerlord.Utils; +using NexusMods.Paths; +using static NexusMods.Games.MountAndBlade2Bannerlord.MountAndBlade2BannerlordConstants; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +/// +/// Maintained by the BUTR Team +/// https://github.com/BUTR +/// +public sealed class MountAndBlade2Bannerlord : AGame, ISteamGame, IGogGame, IEpicGame, IXboxGame +{ + public static readonly GameDomain StaticDomain = GameDomain.From("mountandblade2bannerlord"); + public static string DisplayName => "Mount & Blade II: Bannerlord"; + + private readonly IServiceProvider _serviceProvider; + private readonly LauncherManagerFactory _launcherManagerFactory; + + public IEnumerable SteamIds => new[] { 261550u }; + public IEnumerable GogIds => new long[] { 1802539526, 1564781494 }; + public IEnumerable EpicCatalogItemId => new[] { "Chickadee" }; + public IEnumerable XboxIds => new[] { "TaleWorldsEntertainment.MountBladeIIBannerlord" }; + + public MountAndBlade2Bannerlord(IServiceProvider serviceProvider, LauncherManagerFactory launcherManagerFactory) : base(serviceProvider) + { + _serviceProvider = serviceProvider; + _launcherManagerFactory = launcherManagerFactory; + } + + public override string Name => DisplayName; + public override GameDomain Domain => StaticDomain; + + public override GamePath GetPrimaryFile(GameStore store) => GamePathProvier.PrimaryLauncherFile(store); + + public override IStreamFactory Icon => + new EmbededResourceStreamFactory("NexusMods.Games.MountAndBlade2Bannerlord.Resources.icon.jpg"); + + public override IStreamFactory GameImage => + new EmbededResourceStreamFactory("NexusMods.Games.MountAndBlade2Bannerlord.Resources.game_image.jpg"); + + public override IEnumerable Installers => new IModInstaller[] + { + MountAndBlade2BannerlordModInstaller.Create(_serviceProvider), + }; + + protected override Version GetVersion(GameLocatorResult installation) + { + var launcherManagerHandler = _launcherManagerFactory.Get(installation); + return Version.TryParse(launcherManagerHandler.GetGameVersion(), out var val) ? val : new Version(); + } + + protected override IReadOnlyDictionary GetLocations(IFileSystem fileSystem, GameLocatorResult installation) + { + var documentsFolder = fileSystem.GetKnownPath(KnownPath.MyDocumentsDirectory); + return new Dictionary() + { + { LocationId.Game, installation.Store == GameStore.XboxGamePass ? installation.Path.Combine("Content") : installation.Path }, + { LocationId.Saves, documentsFolder.Combine($"{DocumentsFolderName}/Game Saves") }, + { LocationId.Preferences, documentsFolder.Combine($"{DocumentsFolderName}/Configs") }, + }; + } + + protected override IStandardizedLoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) + { + return new MountAndBlade2BannerlordLoadoutSynchronizer(provider); + } + + public override List GetInstallDestinations(IReadOnlyDictionary locations) + => ModInstallDestinationHelpers.GetCommonLocations(locations); +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs new file mode 100644 index 0000000000..58345ec1ce --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordConstants.cs @@ -0,0 +1,13 @@ +using Bannerlord.LauncherManager; +using NexusMods.Paths; +using NexusMods.Paths.Extensions; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public static class MountAndBlade2BannerlordConstants +{ + public static readonly string DocumentsFolderName = "Mount and Blade II Bannerlord"; + + public static readonly RelativePath ModFolder = Constants.ModulesFolder.ToRelativePath(); + public static readonly RelativePath SubModuleFile = Constants.SubModuleName.ToRelativePath(); +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs new file mode 100644 index 0000000000..3aca78f914 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/MountAndBlade2BannerlordLoadoutSynchronizer.cs @@ -0,0 +1,14 @@ +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.DataModel.LoadoutSynchronizer; +using NexusMods.DataModel.Sorting.Rules; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public sealed class MountAndBlade2BannerlordLoadoutSynchronizer : ALoadoutSynchronizer +{ + public MountAndBlade2BannerlordLoadoutSynchronizer(IServiceProvider provider) : base(provider) { } + + public new ValueTask[]> ModSortRules(Loadout loadout, Mod mod) => base.ModSortRules(loadout, mod); + public new Task> SortMods(Loadout loadout) => base.SortMods(loadout); +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj new file mode 100644 index 0000000000..21e66a7c30 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/NexusMods.Games.MountAndBlade2Bannerlord.csproj @@ -0,0 +1,28 @@ + + + + + $(NoWarn);CS1591; + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg new file mode 100644 index 0000000000..e09b5899b8 Binary files /dev/null and b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/game_image.jpg differ diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg new file mode 100644 index 0000000000..74fdc6e61a Binary files /dev/null and b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Resources/icon.jpg differ diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs new file mode 100644 index 0000000000..5b25ad3283 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunLauncherTool.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NexusMods.DataModel.Games; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.LoadoutSynchronizerDTOs; +using NexusMods.Games.MountAndBlade2Bannerlord.Extensions; +using static NexusMods.Games.MountAndBlade2Bannerlord.Utils.GamePathProvier; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public class RunLauncherTool : ITool +{ + private readonly ILogger _logger; + + public string Name => $"Run Launcher for {MountAndBlade2Bannerlord.DisplayName}"; + public IEnumerable Domains => new[] { MountAndBlade2Bannerlord.StaticDomain }; + + public RunLauncherTool(ILogger logger) + { + _logger = logger; + } + + public async Task Execute(Loadout loadout, CancellationToken ct) + { + if (!loadout.Installation.Is()) return; + + var store = loadout.Installation.Store; + var isXbox = store == GameStore.XboxGamePass; + var useVanillaLauncher = false; // TODO: From Options + var hasBLSE = loadout.HasInstalledFile("Bannerlord.BLSE.Shared.dll"); + if (isXbox && !hasBLSE) return; // Not supported. + + var blseExecutable = useVanillaLauncher + ? BLSELauncherFile(store) + : BLSELauncherExFile(store); + + var program = isXbox + ? blseExecutable + : hasBLSE + ? blseExecutable + : PrimaryLauncherFile(store); + _logger.LogInformation("Running {Program}", program); + + var psi = new ProcessStartInfo(program.ToString()); + var process = Process.Start(psi); + if (process is null) + { + _logger.LogError("Failed to run {Program}", program); + return; + } + + await process.WaitForExitAsync(ct); + _logger.LogInformation("Finished running {Program}", program); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs new file mode 100644 index 0000000000..8644a76ee1 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/RunStandaloneTool.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using NexusMods.DataModel.Games; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.LoadoutSynchronizerDTOs; +using NexusMods.Games.MountAndBlade2Bannerlord.Extensions; +using NexusMods.Games.MountAndBlade2Bannerlord.Services; + +using static NexusMods.Games.MountAndBlade2Bannerlord.Utils.GamePathProvier; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public class RunStandaloneTool : ITool +{ + private readonly ILogger _logger; + private readonly LauncherManagerFactory _launcherManagerFactory; + + public string Name => $"Run {MountAndBlade2Bannerlord.DisplayName}"; + public IEnumerable Domains => new[] { MountAndBlade2Bannerlord.StaticDomain }; + + public RunStandaloneTool(ILogger logger, LauncherManagerFactory launcherManagerFactory) + { + _logger = logger; + _launcherManagerFactory = launcherManagerFactory; + } + + public async Task Execute(Loadout loadout, CancellationToken ct) + { + if (!loadout.Installation.Is()) return; + + var store = loadout.Installation.Store; + var isXbox = store == GameStore.XboxGamePass; + var hasBLSE = loadout.HasInstalledFile("Bannerlord.BLSE.Shared.dll"); + if (isXbox && !hasBLSE) return; // Not supported. + + var program = hasBLSE + ? BLSEStandaloneFile(store) + : PrimaryStandaloneFile(store); + _logger.LogInformation("Running {Program}", program); + + var launcherManager = _launcherManagerFactory.Get(loadout.Installation); + var psi = new ProcessStartInfo(program.ToString()) + { + Arguments = launcherManager.ExecutableParameters + }; + var process = Process.Start(psi); + if (process is null) + { + _logger.LogError("Failed to run {Program}", program); + return; + } + + await process.WaitForExitAsync(ct); + _logger.LogInformation("Finished running {Program}", program); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs new file mode 100644 index 0000000000..f6a7f2fa82 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Common; +using NexusMods.DataModel.Diagnostics.Emitters; +using NexusMods.DataModel.Games; +using NexusMods.DataModel.JsonConverters.ExpressionGenerator; +using NexusMods.Games.MountAndBlade2Bannerlord.Emitters; +using NexusMods.Games.MountAndBlade2Bannerlord.Services; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public static class ServicesExtensions +{ + public static IServiceCollection AddMountAndBladeBannerlord(this IServiceCollection services) + { + services.AddSingleton(); + + services.AddAllSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + return services; + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs new file mode 100644 index 0000000000..a999494850 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerFactory.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using NexusMods.DataModel.Games; +using NexusMods.Games.MountAndBlade2Bannerlord.Utils; +using NexusMods.Paths; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Services; + +public sealed class LauncherManagerFactory +{ + private readonly ILoggerFactory _loggerFactory; + private readonly ConcurrentDictionary _instances = new(); + + public LauncherManagerFactory(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + + public LauncherManagerNexusMods Get(GameInstallation installation) + { + var store = Converter.ToGameStoreTW(installation.Store); + return _instances.GetOrAdd(installation.LocationsRegister[LocationId.Game].ToString(), + static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store)); + } + + public LauncherManagerNexusMods Get(GameLocatorResult gameLocator) + { + var store = Converter.ToGameStoreTW(gameLocator.Store); + return _instances.GetOrAdd(gameLocator.Path.ToString(), + static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store)); + } + + public LauncherManagerNexusMods Get(string installationPath, Bannerlord.LauncherManager.Models.GameStore store) + { + return _instances.GetOrAdd(installationPath, + static (installationPath, tuple) => ValueFactory(tuple._loggerFactory, installationPath, tuple.store), (_loggerFactory, store)); + } + + private static LauncherManagerNexusMods ValueFactory(ILoggerFactory loggerFactory, string installationPath, Bannerlord.LauncherManager.Models.GameStore store) + { + return new LauncherManagerNexusMods(loggerFactory.CreateLogger(), installationPath, store); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs new file mode 100644 index 0000000000..a252b909a5 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.Utils.cs @@ -0,0 +1,20 @@ +using Bannerlord.ModuleManager; +using FetchBannerlordVersion; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Services; + +partial class LauncherManagerNexusMods +{ + public override string GetGameVersion() + { + var gamePath = GetInstallPath(); + var versionStr = Fetcher.GetVersion(gamePath, "TaleWorlds.Library.dll"); + return ApplicationVersion.TryParse(versionStr, out var av) ? $"{av.Major}.{av.Minor}.{av.Revision}.{av.ChangeSet}" : "0.0.0.0"; + } + + public override int GetChangeset() + { + var gamePath = GetInstallPath(); + return Fetcher.GetChangeSet(gamePath, "TaleWorlds.Library.dll"); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs new file mode 100644 index 0000000000..7f4b80316e --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Services/LauncherManagerNexusMods.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Bannerlord.LauncherManager; +using Bannerlord.LauncherManager.Localization; +using Bannerlord.LauncherManager.Models; +using Microsoft.Extensions.Logging; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Services; + +public sealed partial class LauncherManagerNexusMods : LauncherManagerHandler +{ + private readonly ILogger _logger; + private readonly string _installationPath; + private readonly ConcurrentDictionary _notificationIds = new(); + + private Window? _window; // TODO: How to inject the window? + + public string ExecutableParameters { get; private set; } = string.Empty; + + public LauncherManagerNexusMods(ILogger logger, string installationPath, GameStore store) + { + _logger = logger; + _installationPath = installationPath; + + RegisterCallbacks( + loadLoadOrder: null!, // TODO: + saveLoadOrder: null!, // TODO: + sendNotification: SendNotificationDelegate, + sendDialog: SendDialogDelegate, + setGameParameters: (executable, parameters) => ExecutableParameters = string.Join(" ", parameters), + getInstallPath: () => installationPath, + readFileContent: ReadFileContentDelegate, + writeFileContent: WriteFileContentDelegate, + readDirectoryFileList: s => Directory.Exists(s) ? Directory.GetFiles(s) : null, + readDirectoryList: s => Directory.Exists(s) ? Directory.GetDirectories(s) : null, + getAllModuleViewModels: null!, // TODO: + getModuleViewModels: null!, // TODO: + setModuleViewModels: null!, // TODO: + getOptions: null!, // TODO: + getState: null! // TODO: + ); + SetGameStore(store); + } + + public void SetCurrentWindow(Window window) + { + _window = window; + } + + private void SendNotificationDelegate(string id, NotificationType type, string message, uint ms) + { + if (_window is null) + { + return; + } + + if (string.IsNullOrEmpty(id)) id = Guid.NewGuid().ToString(); + + // Prevents message spam + if (_notificationIds.TryAdd(id, null)) return; + using var cts = new CancellationTokenSource(); + _ = Task.Delay(TimeSpan.FromMilliseconds(ms), cts.Token).ContinueWith(x => _notificationIds.TryRemove(id, out _), cts.Token); + + var translatedMessage = new BUTRTextObject(message).ToString(); + switch (type) + { + case NotificationType.Hint: + { + //HintManager.ShowHint(translatedMessage); + cts.Cancel(); + break; + } + case NotificationType.Info: + { + // TODO: + //HintManager.ShowHint(translatedMessage); + cts.Cancel(); + break; + } + default: + //MessageBox.Show(translatedMessage); + cts.Cancel(); + break; + } + } + + private void SendDialogDelegate(DialogType type, string title, string message, IReadOnlyList filters, Action onResult) + { + if (_window is null) + { + onResult(string.Empty); + return; + } + + switch (type) + { + case DialogType.Warning: + { + var split = message.Split(new[] { "--CONTENT-SPLIT--" }, StringSplitOptions.RemoveEmptyEntries); + /* + using var okButton = new TaskDialogButton(ButtonType.Yes); + using var cancelButton = new TaskDialogButton(ButtonType.No); + using var dialog = new TaskDialog + { + MainIcon = TaskDialogIcon.Warning, + WindowTitle = new BUTRTextObject(title).ToString(), + MainInstruction = split[0], + Content = split.Length > 1 ? split[1] : string.Empty, + Buttons = { okButton, cancelButton }, + CenterParent = true, + AllowDialogCancellation = true, + }; + onResult(dialog.ShowDialog() == okButton ? "true" : "false"); + */ + return; + } + case DialogType.FileOpen: + { + _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + FileTypeFilter = filters.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Extensions }).ToArray(), + AllowMultiple = false + }).ContinueWith(x => onResult(x.Result.Count < 1 ? string.Empty : x.Result[0].Path.ToString())); + return; + } + case DialogType.FileSave: + { + var fileName = message; + _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + FileTypeChoices = filters.Select(x => new FilePickerFileType(x.Name) { Patterns = x.Extensions }).ToArray(), + SuggestedFileName = fileName, + ShowOverwritePrompt = true + }).ContinueWith(x => onResult(x.Result is null ? string.Empty : x.Result.Path.ToString())); + return; + } + } + } + + + private byte[]? ReadFileContentDelegate(string path, int offset, int length) + { + if (!File.Exists(path)) return null; + + try + { + if (offset == 0 && length == -1) + { + return File.ReadAllBytes(path); + } + else if (offset >= 0 && length > 0) + { + var data = new byte[length]; + using var handle = File.OpenHandle(path, options: FileOptions.RandomAccess); + RandomAccess.Read(handle, data, offset); + return data; + } + else + { + return null; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Bannerlord IO Read Operation failed! {Path}", path); + return null; + } + } + + private void WriteFileContentDelegate(string path, byte[]? data) + { + if (!File.Exists(path)) return; + + try + { + if (data is null) + File.Delete(path); + else + File.WriteAllBytes(path, data); + } + catch (Exception ex) + { + _logger.LogError(ex, "Bannerlord IO Write Operation failed! {Path}", path); + } + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs new file mode 100644 index 0000000000..5c75d39c55 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Sorters/ModuleInfoSort.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using Bannerlord.ModuleManager; +using JetBrains.Annotations; +using NexusMods.DataModel.JsonConverters; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.DataModel.Sorting.Rules; +using NexusMods.DataModel.TriggerFilter; +using NexusMods.Games.MountAndBlade2Bannerlord.Extensions; +using NexusMods.Hashing.xxHash64; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Sorters; + +[PublicAPI] +[JsonName("MountAndBlade2Bannerlord.Sorters.ModuleInfoSort")] +public class ModuleInfoSort : IGeneratedSortRule, ISortRule, ITriggerFilter +{ + public ITriggerFilter TriggerFilter => this; + + private static async IAsyncEnumerable> GetRules(ModuleInfoExtended moduleInfo, Loadout loadout) + { + ModId? GetModIdFromModuleId(string moduleId) => loadout.Mods.Values.FirstOrDefault(x => x.GetModuleInfo() is { } mi && mi.Id == moduleId)?.Id; + + await Task.Yield(); + + foreach (var moduleMetadata in moduleInfo.DependenciesLoadBeforeThisDistinct()) + { + if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId) + { + yield return new After { Other = modId }; + } + } + foreach (var moduleMetadata in moduleInfo.DependenciesLoadAfterThisDistinct()) + { + if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId) + { + yield return new Before { Other = modId }; + } + } + foreach (var moduleMetadata in moduleInfo.DependenciesIncompatiblesDistinct()) + { + if (GetModIdFromModuleId(moduleMetadata.Id) is { } modId) + { + // If an incompatible module was detected, the dependency rules were not respected + throw new UnreachableException(); + } + } + } + + public IAsyncEnumerable> GenerateSortRules(ModId selfId, Loadout loadout) + { + var thisMod = loadout.Mods[selfId]; + return thisMod.GetModuleInfo() is { } moduleInfo ? GetRules(moduleInfo, loadout) : AsyncEnumerable.Empty>(); + } + + // From what I guess, we will need to re-sort either when a mod was added/removed or a mod version changed + // We could only consider the mods that are relevant for the self mod, but not sure if this will work correct + // Investigate once testing is available + public Hash GetFingerprint(ModId self, Loadout loadout) + { + var moduleInfos = loadout.Mods.Select(x => x.Value.GetModuleInfo()).OfType().OrderBy(x => x.Id).ToArray(); + + using var fp = Fingerprinter.Create(); + fp.Add(loadout.Mods[self].DataStoreId); + foreach (var moduleInfo in moduleInfos) + { + fp.Add(moduleInfo.Id); + fp.Add(moduleInfo.Version.ToString()); + } + return fp.Digest(); + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs new file mode 100644 index 0000000000..c1bbd8be4a --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/TypeFinder.cs @@ -0,0 +1,20 @@ +using NexusMods.DataModel.JsonConverters.ExpressionGenerator; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; +using NexusMods.Games.MountAndBlade2Bannerlord.Sorters; + +namespace NexusMods.Games.MountAndBlade2Bannerlord; + +public class TypeFinder : ITypeFinder +{ + public IEnumerable DescendentsOf(Type type) + { + return AllTypes.Where(t => t.IsAssignableTo(type)); + } + + private static IEnumerable AllTypes => new[] + { + typeof(ModuleFileMetadata), + typeof(SubModuleFileMetadata), + typeof(ModuleInfoSort), + }; +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs new file mode 100644 index 0000000000..7ba29c71d8 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/Converter.cs @@ -0,0 +1,20 @@ +using GameStore = NexusMods.DataModel.Games.GameStore; +using GameStoreTW = Bannerlord.LauncherManager.Models.GameStore; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils; + +public static class Converter +{ + public static GameStoreTW ToGameStoreTW(GameStore store) + { + if (store == GameStore.Steam) + return GameStoreTW.Steam; + if (store == GameStore.GOG) + return GameStoreTW.GOG; + if (store == GameStore.EGS) + return GameStoreTW.Epic; + if (store == GameStore.XboxGamePass) + return GameStoreTW.Xbox; + return GameStoreTW.Unknown; + } +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs new file mode 100644 index 0000000000..33640046cb --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/GamePathProvier.cs @@ -0,0 +1,30 @@ +using Bannerlord.LauncherManager; +using NexusMods.Paths; +using NexusMods.Paths.Extensions; +using GameStore = NexusMods.DataModel.Games.GameStore; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils; + +public static class GamePathProvier +{ + private static string GetConfiguration(GameStore store) => + LauncherManagerHandler.GetConfigurationByPlatform(LauncherManagerHandler.FromStore(Converter.ToGameStoreTW(store))); + + public static GamePath PrimaryLauncherFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("TaleWorlds.MountAndBlade.Launcher.exe")); + + public static GamePath PrimaryXboxLauncherFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Launcher.Native.exe")); + + public static GamePath PrimaryStandaloneFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join(Constants.BannerlordExecutable)); + + public static GamePath BLSEStandaloneFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.Standalone.exe")); + + public static GamePath BLSELauncherFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.Launcher.exe")); + + public static GamePath BLSELauncherExFile(GameStore store) => + new(LocationId.Game, Path.Combine("bin", GetConfiguration(store)).ToRelativePath().Join("Bannerlord.BLSE.LauncherEx.exe")); +} diff --git a/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs new file mode 100644 index 0000000000..a5e959e2c7 --- /dev/null +++ b/src/Games/NexusMods.Games.MountAndBlade2Bannerlord/Utils/ModuleContext.cs @@ -0,0 +1,52 @@ +using Bannerlord.LauncherManager; +using Bannerlord.ModuleManager; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Utils; + +internal class ModuleContext +{ + private readonly IDictionary _lookup; + public ModuleContext(IEnumerable moduleVMs) + { + _lookup = moduleVMs.ToDictionary(x => x.ModuleInfoExtended.Id, x => x); + } + public ModuleContext(IDictionary lookup) + { + _lookup = lookup; + } + + public bool GetIsValid(ModuleInfoExtended module) + { + if (FeatureIds.LauncherFeatures.Contains(module.Id)) + return true; + + return _lookup[module.Id].IsValid; + } + public bool GetIsSelected(ModuleInfoExtended module) + { + if (FeatureIds.LauncherFeatures.Contains(module.Id)) + return false; + + return _lookup[module.Id].IsSelected; + } + public void SetIsSelected(ModuleInfoExtended module, bool value) + { + if (FeatureIds.LauncherFeatures.Contains(module.Id)) + return; + _lookup[module.Id].IsSelected = value; + } + public bool GetIsDisabled(ModuleInfoExtended module) + { + if (FeatureIds.LauncherFeatures.Contains(module.Id)) + return false; + + return _lookup[module.Id].IsDisabled; + } + public void SetIsDisabled(ModuleInfoExtended module, bool value) + { + if (FeatureIds.LauncherFeatures.Contains(module.Id)) + return; + _lookup[module.Id].IsDisabled = value; + } +} diff --git a/src/NexusMods.App/NexusMods.App.csproj b/src/NexusMods.App/NexusMods.App.csproj index 568d5b6450..b3f74f3502 100644 --- a/src/NexusMods.App/NexusMods.App.csproj +++ b/src/NexusMods.App/NexusMods.App.csproj @@ -15,6 +15,7 @@ + diff --git a/src/NexusMods.App/Services.cs b/src/NexusMods.App/Services.cs index 374f6d6b2e..6d7ea9566b 100644 --- a/src/NexusMods.App/Services.cs +++ b/src/NexusMods.App/Services.cs @@ -15,6 +15,7 @@ using NexusMods.Games.FOMOD; using NexusMods.Games.FOMOD.UI; using NexusMods.Games.Generic; +using NexusMods.Games.MountAndBlade2Bannerlord; using NexusMods.Games.RedEngine; using NexusMods.Games.Reshade; using NexusMods.Games.Sifu; @@ -69,6 +70,7 @@ public static IServiceCollection AddApp(this IServiceCollection services, .AddDarkestDungeon() .AddSifu() .AddStardewValley() + .AddMountAndBladeBannerlord() .AddRenderers() .AddNexusWebApi() .AddNexusWebApiNmaIntegration() diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig new file mode 100644 index 0000000000..ece3986bd3 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] +max_line_length = 180 + +space_within_single_line_array_initializer_braces = true \ No newline at end of file diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs new file mode 100644 index 0000000000..d09fc452fc --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Emitters/BuiltInEmitterTests.cs @@ -0,0 +1,47 @@ +using Bannerlord.ModuleManager; +using FluentAssertions; +using NexusMods.DataModel.Diagnostics; +using NexusMods.DataModel.Diagnostics.References; +using NexusMods.Games.MountAndBlade2Bannerlord.Emitters; +using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; +using NexusMods.Games.TestFramework; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Emitters; + +public class BuiltInEmitterTests : ALoadoutDiagnosticEmitterTest +{ + public BuiltInEmitterTests(IServiceProvider serviceProvider) : base(serviceProvider) { } + + [Fact] + public async Task Test_Emitter() + { + var loadoutMarker = await CreateLoadout(); + + var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout); + + await loadoutMarker.AddNative(context); + await loadoutMarker.AddButterLib(context); + await loadoutMarker.AddHarmony(context); + + var diagnostics = await GetAllDiagnostics(loadoutMarker.Value); + diagnostics.Should().BeEmpty(); + } + + [Fact] + public async Task Test_Emitter2() + { + var loadoutMarker = await CreateLoadout(); + + var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout); + + await loadoutMarker.AddNative(context); + var modA = await loadoutMarker.AddButterLib(context); + + var diagnostic = await GetSingleDiagnostic(loadoutMarker.Value); + diagnostic.Id.Should().Be(new DiagnosticId(BuiltInEmitter.Source, (ushort) ModuleIssueType.MissingDependencies)); + diagnostic.DataReferences.Should().Equal( + loadoutMarker.Value.ToReference(), + modA.ToReference(loadoutMarker.Value) + ); + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs new file mode 100644 index 0000000000..2e51fb6a34 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Installers/MountAndBlade2BannerlordModInstallerTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using FluentAssertions; +using NexusMods.DataModel.Loadouts.ModFiles; +using NexusMods.Games.MountAndBlade2Bannerlord.Installers; +using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; +using NexusMods.Games.TestFramework; +using NexusMods.Networking.NexusWebApi.Types; +using NexusMods.Paths; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Installers; + +public class MountAndBlade2BannerlordModInstallerTests : AModInstallerTest +{ + public MountAndBlade2BannerlordModInstallerTests(IServiceProvider serviceProvider) : base(serviceProvider) { } + + [Fact] + [Trait("RequiresNetworking", "True")] + public async Task Test_WithBLSE() + { + var loadout = await CreateLoadout(); + + // Bannerlord Software Extender (BLSE): Bannerlord Software Extender (BLSE) Main File (Version 1.3.6) + var downloadId = await DownloadMod(Game.Domain, ModId.From(1), FileId.From(34698)); + + var mod = await InstallModStoredFileIntoLoadout(loadout, downloadId); + mod.Files.Should().HaveCount(11); + mod.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("bin")); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Shared.dll"); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Standalone.exe"); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.Launcher.exe"); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.BLSE.LauncherEx.exe"); + } + + [Fact] + [Trait("RequiresNetworking", "True")] + public async Task Test_WithStandardMod() + { + var loadout = await CreateLoadout(false); + + // Harmony: Harmony Main File (Version 2.3.0) + var downloadId = await DownloadMod(Game.Domain, ModId.From(2006), FileId.From(34666)); + + var mod = await InstallModStoredFileIntoLoadout(loadout, downloadId); + mod.Name.Should().BeEquivalentTo("Harmony"); + mod.Version.Should().BeEquivalentTo("v2.3.0.0"); + mod.Files.Values.Cast().Should().HaveCount(49); + mod.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test")); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll"); + mod.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml"); + } + + [Fact] + [Trait("RequiresNetworking", "True")] + public async Task Test_WithStandardModMultiple() + { + var loadout = await CreateLoadout(false); + + // Calradia at War (Custom Spawns): CalradiaAtWar For Bannerlord v1.1.0 - v1.1.1 - v1.1.2 Main File (Version 1.9.5) + var downloadId = await DownloadMod(Game.Domain, ModId.From(411), FileId.From(34610)); + + var mods = await InstallModsStoredFileIntoLoadout(loadout, downloadId); + var mod1 = mods.FirstOrDefault(x => x.Name == "Custom Spawns API"); + var mod2 = mods.FirstOrDefault(x => x.Name == "Calradia At War"); + + mod1.Name.Should().BeEquivalentTo("Custom Spawns API"); + mod1.Version.Should().BeEquivalentTo("v1.9.5.0"); + mod1.Files.Should().HaveCount(8); + mod1.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/CustomSpawns")); + mod1.Files.Values.Cast().Should().Contain(x => x.To.FileName == "CustomSpawns.dll"); + mod1.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml"); + + mod2.Name.Should().BeEquivalentTo("Calradia At War"); + mod2.Version.Should().BeEquivalentTo("v1.9.1.0"); + mod2.Files.Values.Cast().Should().HaveCount(20); + mod2.Files.Values.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/CalradiaAtWar")); + mod2.Files.Values.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml"); + } + + [Fact] + public async Task Test_WithFakeMod() + { + var testFiles = new Dictionary + { + ["Test/SubModule.xml"] = Encoding.UTF8.GetBytes(Data.HarmonySubModuleXml), + ["Test/bin/Win64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty(), + ["Test/bin/Gaming.Desktop.x64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty() + }; + + var file = await CreateTestArchive(testFiles); + await using (file) + { + var (mod, modFiles) = await GetModWithFilesFromInstaller(file.Path); + mod.Name.Should().BeEquivalentTo("Harmony"); + mod.Version.Should().BeEquivalentTo("v2.2.0.0"); + modFiles.Should().HaveCount(3); + modFiles.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test")); + modFiles.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll"); + modFiles.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml"); + } + } + [Fact] + public async Task Test_WithFakeMod_WithModulesRoot() + { + var testFiles = new Dictionary + { + ["Modules/Test/SubModule.xml"] = Encoding.UTF8.GetBytes(Data.HarmonySubModuleXml), + ["Modules/Test/bin/Win64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty(), + ["Modules/Test/bin/Gaming.Desktop.x64_Shipping_Client/Bannerlord.Harmony.dll"] = Array.Empty() + }; + + var file = await CreateTestArchive(testFiles); + await using (file) + { + var (mod, modFiles) = await GetModWithFilesFromInstaller(file.Path); + mod.Name.Should().BeEquivalentTo("Harmony"); + mod.Version.Should().BeEquivalentTo("v2.2.0.0"); + modFiles.Should().HaveCount(3); + modFiles.Cast().Should().AllSatisfy(x => x.To.Path.StartsWith("Modules/Test")); + modFiles.Cast().Should().Contain(x => x.To.FileName == "Bannerlord.Harmony.dll"); + modFiles.Cast().Should().Contain(x => x.To.FileName == "SubModule.xml"); + } + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj new file mode 100644 index 0000000000..07fecb40ef --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/NexusMods.Games.MountAndBlade2Bannerlord.Tests.csproj @@ -0,0 +1,16 @@ + + + NexusMods.Games.MountAndBlade2Bannerlord.Tests + + + + + + + + + + PreserveNewest + + + diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs new file mode 100644 index 0000000000..80214c9198 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/AGameTestContext.cs @@ -0,0 +1,22 @@ +using NexusMods.DataModel.Loadouts.Markers; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.Paths; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; + +public class AGameTestContext +{ + public static AGameTestContext Create( + Func, Task> createTestArchive, + Func> installModStoredFileIntoLoadout) => + new(createTestArchive, installModStoredFileIntoLoadout); + + public Func, Task> CreateTestArchive { get; } + public Func> InstallModStoredFileIntoLoadout { get; } + + private AGameTestContext(Func, Task> createTestArchive, Func> installModStoredFileIntoLoadout) + { + CreateTestArchive = createTestArchive; + InstallModStoredFileIntoLoadout = installModStoredFileIntoLoadout; + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs new file mode 100644 index 0000000000..3dd40ac5e6 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/Data.cs @@ -0,0 +1,108 @@ +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; + +public static class Data +{ + public static readonly string HarmonySubModuleXml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"""; + + public static readonly string ButterLibSubModuleXml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"""; +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs new file mode 100644 index 0000000000..11421f14fa --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/LoadoutMarkerExtensions.cs @@ -0,0 +1,66 @@ +using System.Xml; +using Bannerlord.ModuleManager; +using NexusMods.DataModel.Loadouts.Markers; +using NexusMods.DataModel.Loadouts.Mods; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; + +public static class LoadoutMarkerExtensions +{ + public static async Task AddNative(this LoadoutMarker loadoutMarker, AGameTestContext context) + { + var moduleInfo = new ModuleInfoExtended + { + Id = "Native", + Name = "Native", + Version = ApplicationVersion.TryParse("v1.0.0.0", out var bVersion) ? bVersion : ApplicationVersion.Empty, + }; + + var modFiles = moduleInfo.CreateTestFiles(); + await using var modPath = await context.CreateTestArchive(modFiles); + + return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None); + } + + public static async Task AddHarmony(this LoadoutMarker loadoutMarker, AGameTestContext context) + { + var doc = new XmlDocument(); + doc.LoadXml(Data.HarmonySubModuleXml); + var moduleInfo = ModuleInfoExtended.FromXml(doc); + + var modFiles = moduleInfo.CreateTestFiles(); + await using var modPath = await context.CreateTestArchive(modFiles); + + return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None); + } + + public static async Task AddButterLib(this LoadoutMarker loadoutMarker, AGameTestContext context) + { + var doc = new XmlDocument(); + doc.LoadXml(Data.ButterLibSubModuleXml); + var moduleInfo = ModuleInfoExtended.FromXml(doc); + + var modFiles = moduleInfo.CreateTestFiles(); + await using var modPath = await context.CreateTestArchive(modFiles); + + return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None); + } + + public static async Task AddFakeButterLib(this LoadoutMarker loadoutMarker, AGameTestContext context) + { + var moduleInfo = new ModuleInfoExtended + { + Id = "Bannerlord.ButterLib", + Name = "ButterLib", + Version = ApplicationVersion.TryParse("v1.0.0.0", out var bVersion) ? bVersion : ApplicationVersion.Empty, + DependentModuleMetadatas = new [] + { + new DependentModuleMetadata("Bannerlord.Harmony", LoadType.LoadBeforeThis, false, false, ApplicationVersion.TryParse("v3.0.0.0", out var a2Version) ? a2Version : ApplicationVersion.Empty, ApplicationVersionRange.Empty) + } + }; + var modFiles = moduleInfo.CreateTestFiles(); + await using var modPath = await context.CreateTestArchive(modFiles); + + return await context.InstallModStoredFileIntoLoadout(loadoutMarker, modPath, null, CancellationToken.None); + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs new file mode 100644 index 0000000000..504a20b596 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Shared/ModuleInfoExtendedExtensions.cs @@ -0,0 +1,100 @@ +using System.Text; +using System.Xml; +using Bannerlord.ModuleManager; +using NexusMods.Paths; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; + +public static class ModuleInfoExtendedExtensions +{ + public static Dictionary CreateTestFiles(this ModuleInfoExtended moduleInfo) + { + var xml = GetXml(moduleInfo); + var bytes = Encoding.UTF8.GetBytes(xml); + + return new Dictionary + { + { "SubModule.xml", bytes } + }; + } + + public static string GetXml(this ModuleInfoExtended moduleInfo) + { + var doc = new XmlDocument(); + + var xmlDeclaration = doc.CreateXmlDeclaration( "1.0", "UTF-8", null ); + var root = doc.DocumentElement; + doc.InsertBefore(xmlDeclaration, root); + + var module = doc.CreateElement(string.Empty, "Module", string.Empty); + doc.AppendChild(module); + + var id = doc.CreateElement(string.Empty, "Id", string.Empty); + id.SetAttribute("value", moduleInfo.Id); + module.AppendChild(id); + + var name = doc.CreateElement(string.Empty, "Name", string.Empty); + name.SetAttribute("value", moduleInfo.Name); + module.AppendChild(name); + + var version = doc.CreateElement(string.Empty, "Version", string.Empty); + version.SetAttribute("value", moduleInfo.Version.ToString()); + module.AppendChild(version); + + var defaultModule = doc.CreateElement(string.Empty, "DefaultModule", string.Empty); + defaultModule.SetAttribute("value", moduleInfo.IsOfficial ? "false" : "true"); + module.AppendChild(defaultModule); + + var moduleCategory = doc.CreateElement(string.Empty, "ModuleCategory", string.Empty); + moduleCategory.SetAttribute("value", moduleInfo.IsSingleplayerModule ? "Singleplayer" : moduleInfo.IsMultiplayerModule ? "Multiplayer" : moduleInfo.IsServerModule ? "Server" : "None"); + module.AppendChild(moduleCategory); + + var moduleType = doc.CreateElement(string.Empty, "ModuleType", string.Empty); + moduleType.SetAttribute("value", moduleInfo.IsOfficial ? "Official" : "Community"); + module.AppendChild(moduleType); + + var url = doc.CreateElement(string.Empty, "Url", string.Empty); + url.SetAttribute("value", moduleInfo.Url); + module.AppendChild(url); + + var dependedModuleMetadatas = doc.CreateElement(string.Empty, "DependedModuleMetadatas", string.Empty); + module.AppendChild(dependedModuleMetadatas); + foreach (var dependency in moduleInfo.DependenciesToLoadDistinct()) + { + var dep = doc.CreateElement(string.Empty, "DependedModuleMetadata", string.Empty); + dep.SetAttribute("id", dependency.Id); + dep.SetAttribute("order", dependency.LoadType.ToString()); + dep.SetAttribute("optional", dependency.IsOptional ? "true" : "false"); + dependedModuleMetadatas.AppendChild(dep); + } + + var subModules = doc.CreateElement(string.Empty, "SubModules", string.Empty); + module.AppendChild(subModules); + foreach (var subModule in moduleInfo.SubModules) + { + var sub = doc.CreateElement(string.Empty, "SubModule", string.Empty); + + var subName = doc.CreateElement(string.Empty, "Name", string.Empty); + subName.SetAttribute("value", subModule.Name); + sub.AppendChild(subName); + + var dllName = doc.CreateElement(string.Empty, "DLLName", string.Empty); + dllName.SetAttribute("value", subModule.DLLName); + sub.AppendChild(dllName); + + var subModuleClassType = doc.CreateElement(string.Empty, "SubModuleClassType", string.Empty); + dllName.SetAttribute("value", subModule.SubModuleClassType); + sub.AppendChild(subModuleClassType); + + var assemblies = doc.CreateElement(string.Empty, "Assemblies", string.Empty); + foreach (var assembly in subModule.Assemblies) + { + var ass = doc.CreateElement(string.Empty, "Assembly", string.Empty); + ass.SetAttribute("value", assembly); + assemblies.AppendChild(ass); + } + } + + return doc.OuterXml; + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs new file mode 100644 index 0000000000..42d33d81e4 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Sorters/MountAndBlade2BannerlordLoadoutSynchronizerTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.DataModel.Sorting.Rules; +using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; +using NexusMods.Games.TestFramework; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Sorters; + +public class MountAndBlade2BannerlordLoadoutSynchronizerTests : AGameTest +{ + public MountAndBlade2BannerlordLoadoutSynchronizerTests(IServiceProvider provider) : base(provider) { } + + [Fact] + public async Task GeneratedSortRulesAreFetched() + { + var loadoutMarker = await CreateLoadout(); + var loadoutSynchronizer = (loadoutMarker.Value.Installation.Game.Synchronizer as MountAndBlade2BannerlordLoadoutSynchronizer)!; + + var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout); + + await loadoutMarker.AddNative(context); + await loadoutMarker.AddButterLib(context); + await loadoutMarker.AddHarmony(context); + + var mod = loadoutMarker.Value.Mods.Values.First(m => m.Name == "ButterLib"); + var nameForId = loadoutMarker.Value.Mods.Values.ToDictionary(m => m.Id, m => m.Name); + var rules = await loadoutSynchronizer.ModSortRules(loadoutMarker.Value, mod); + + var testData = rules.Select(r => + { + if (r is After a) return ("After", nameForId[a.Other]); + if (r is Before b) return ("Before", nameForId[b.Other]); + throw new NotImplementedException(); + }); + + testData.Should().BeEquivalentTo(new[] + { + ("After", "Harmony"), + ("Before", "Native") + }); + } +} diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs new file mode 100644 index 0000000000..9f91cde789 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Startup.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Common; +using NexusMods.Games.TestFramework; +using NexusMods.StandardGameLocators.TestHelpers; +using Xunit.DependencyInjection.Logging; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services + .AddDefaultServicesForTesting() + .AddUniversalGameLocator(new Version("1.0.0.0")) + .AddMountAndBladeBannerlord() + .AddLogging(builder => builder.AddXunitOutput()) + .Validate(); + } +} + diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs new file mode 100644 index 0000000000..59d99ab327 --- /dev/null +++ b/tests/Games/NexusMods.Games.MountAndBlade2Bannerlord.Tests/Utils/LoadoutExtensionsTests.cs @@ -0,0 +1,50 @@ +using Bannerlord.LauncherManager.Models; +using FluentAssertions; +using NexusMods.DataModel.Loadouts; +using NexusMods.DataModel.Loadouts.Mods; +using NexusMods.Games.MountAndBlade2Bannerlord.Extensions; +using NexusMods.Games.MountAndBlade2Bannerlord.Models; +using NexusMods.Games.MountAndBlade2Bannerlord.Tests.Shared; +using NexusMods.Games.TestFramework; + +namespace NexusMods.Games.MountAndBlade2Bannerlord.Tests.Utils; + +public class LoadoutExtensionsTests : AGameTest +{ + public LoadoutExtensionsTests(IServiceProvider serviceProvider) : base(serviceProvider) { } + + private static LoadoutModuleViewModel ViewModelCreator(Mod mod, ModuleInfoExtendedWithPath moduleInfo, int index) => new() + { + Mod = mod, + ModuleInfoExtended = moduleInfo, + IsValid = true, + IsSelected = mod.Enabled, + IsDisabled = mod.Status == ModStatus.Failed, + Index = index, + }; + + [Fact] + public async Task Test_GetViewModels() + { + var loadoutMarker = await CreateLoadout(); + + var context = AGameTestContext.Create(CreateTestArchive, InstallModStoredFileIntoLoadout); + + await loadoutMarker.AddButterLib(context); + await loadoutMarker.AddHarmony(context); + + var unsorted = loadoutMarker.Value.GetViewModels(ViewModelCreator).Select(x => x.Mod.Name).ToList(); + var sorted = (await loadoutMarker.Value.GetSortedViewModelsAsync(ViewModelCreator)).Select(x => x.Mod.Name).ToList(); + + unsorted.Should().BeEquivalentTo(new[] + { + "ButterLib", + "Harmony", + }); + sorted.Should().BeEquivalentTo(new[] + { + "Harmony", + "ButterLib", + }); + } +}