diff --git a/.gitignore b/.gitignore index 811814afa..2461b9473 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ crashlytics-build.properties Packages/packages-lock.json GameData/run.cc GameData/player.cc +GameData/RemovedMods/ Vault/Aetheria/.obsidian/plugins/copilot/data.json # Agent scratch, sandboxed toolchain caches, and throwaway checkouts. These had diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 653becda2..35dbc24c6 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -189,6 +189,28 @@ public static class ShipModCatalog return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded); } + // The one uninstall: presence under Mods is the only enablement (MR1), so a package leaves by moving, never by deletion, + // to /RemovedMods/ (' (2)' and on when taken). Moving it back reinstalls it. + // Returns the destination. Compose and ResolveCatalog never read RemovedMods. + public static string RemovePackage(string modsRoot, string package) + { + if (string.IsNullOrEmpty(package) || package == "." || package == ".." || package == "(all mods)" || + package != Path.GetFileName(package) || package.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("Not a mod package name.", nameof(package)); + var source = Path.Combine(Path.GetFullPath(modsRoot), package); + if (!Directory.Exists(source)) throw new DirectoryNotFoundException("The package is not a directory under the mods folder."); + var removed = RemovedRoot(modsRoot); + Directory.CreateDirectory(removed); + var destination = Path.Combine(removed, package); + for (var copy = 2; Directory.Exists(destination) || File.Exists(destination); copy++) + destination = Path.Combine(removed, $"{package} ({copy})"); + Directory.Move(source, destination); + return destination; + } + + private static string RemovedRoot(string modsRoot) => + Path.Combine(Path.GetDirectoryName(Path.GetFullPath(modsRoot)), "RemovedMods"); + private static Package Bind(HullData hull, ShipAuthoring ship, FactionProductData[] products, string directory, string path) { if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 188a82474..103755402 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -582,6 +582,53 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Contains(ShipModCatalog.HullKey("mod.c").Value, Records(Derived).Keys); } + private string RemovedMods => Path.Combine(_directory.Path, "RemovedMods"); + + [Fact] + public void RemovePackageMovesItAsideAndTheNextBootDoesNotLoadIt() + { + WritePackage("mod.skiff"); + var before = (Ship: Hash(Path.Combine(Mods, "mod.skiff", "ship.cc")), Glb: Hash(Path.Combine(Mods, "mod.skiff", "skiff.glb"))); + Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); + + var destination = ShipModCatalog.RemovePackage(Mods, "mod.skiff"); + + Assert.Equal(Path.Combine(RemovedMods, "mod.skiff"), destination); + Assert.False(Directory.Exists(Path.Combine(Mods, "mod.skiff"))); + Assert.Equal(before, (Hash(Path.Combine(destination, "ship.cc")), Hash(Path.Combine(destination, "skiff.glb")))); + Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog); + } + + [Fact] + public void RemovingAPackageTwiceKeepsBothCopies() + { + WritePackage("mod.skiff"); + ShipModCatalog.RemovePackage(Mods, "mod.skiff"); + File.WriteAllText(Path.Combine(RemovedMods, "mod.skiff", "marker.txt"), "first"); + WritePackage("mod.skiff"); + var second = ShipModCatalog.RemovePackage(Mods, "mod.skiff"); + + Assert.Equal(Path.Combine(RemovedMods, "mod.skiff (2)"), second); + Assert.Equal("first", File.ReadAllText(Path.Combine(RemovedMods, "mod.skiff", "marker.txt"))); + Assert.False(File.Exists(Path.Combine(second, "marker.txt"))); + } + + [Fact] + public void RemovePackageRefusesWhatIsNotAPackageDirectory() + { + WritePackage("mod.skiff"); + File.WriteAllText(Path.Combine(_directory.Path, "outside.txt"), "x"); + Directory.CreateDirectory(Path.Combine(_directory.Path, "Mods2")); + foreach (var name in new[] { "", ".", "..", "(all mods)", "a/b", "a\b", "../Mods", "missing" }) + Assert.ThrowsAny(() => ShipModCatalog.RemovePackage(Mods, name)); + Assert.True(Directory.Exists(Mods)); + Assert.True(Directory.Exists(Path.Combine(Mods, "mod.skiff"))); + Assert.True(Directory.Exists(Path.Combine(_directory.Path, "Mods2"))); + Assert.True(File.Exists(Path.Combine(_directory.Path, "outside.txt"))); + Assert.False(Directory.Exists(RemovedMods)); + Assert.Equal(new[] { "mod.skiff" }, Directory.GetDirectories(Mods).Select(Path.GetFileName)); + } + [Fact] public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled() {