ShipModCatalog.RemovePackage: the one uninstall moves a mod aside to GameData/RemovedMods

Presence under Mods is the only enablement; removal is a move, never a delete,
and moving a package back reinstalls it. Refuses names that are not a package
directory directly under the mods root. Not yet built or run; verify runs at
the end of the cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 21:47:38 +02:00
commit e04e247820
3 changed files with 70 additions and 0 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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 <parent of modsRoot>/RemovedMods/<package> ('<package> (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))

View file

@ -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<Exception>(() => 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()
{