Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack)

The catalog is a stack of packages, the shipped content included, so a Loadout may
name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no
ruling) is deleted, and its test now pins that a package design commits under its key.
The IsModKey comment no longer claims the shipped catalog never references a mod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-21 20:32:11 +02:00
commit 7f1337167d
3 changed files with 14 additions and 16 deletions

View file

@ -99,14 +99,13 @@ public static class Loadouts
// The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
// file; a cache that already has the catalog open sees the loadout only once it reloads.
// Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
// The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may
// name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged.
// Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
// on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
if (!File.Exists(catalogPath))
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);

View file

@ -25,7 +25,8 @@ public static class ShipModCatalog
public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value);
// A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled.
// A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed
// fails closed where it is read.
public static bool IsModKey(CultRecordKey key) =>
key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal));

View file

@ -122,27 +122,25 @@ public sealed class LoadoutTests : IDisposable
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
}
// The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The
// refusal is at the only writer, so no caller (the editor command included) can get one into the file.
// The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or
// slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is
// built, not here.
[Theory]
[InlineData("hull", "mod-hull:mod.skiff")]
[InlineData("slot", "mod-hull:mod.skiff")]
[InlineData("slot", "mod-ship:mod.skiff")]
public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key)
public void APresetNamingAPackageDesignIsCommitted(string where, string key)
{
var mod = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
using var cache = Open();
var loadout = HandBuilt(cache);
if (where == "hull") loadout.Hull = new CultRecordRef<HullData>(new CultRecordKey(key));
else loadout.Slots[1].Design = mod;
var before = Hash(Catalog);
else loadout.Slots[1].Design = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, loadout, replace: false));
Assert.True(Loadouts.Commit(Catalog, loadout, replace: false));
Assert.Contains("names mod designs", error.Message);
Assert.Contains(key, error.Message);
Assert.Equal(before, Hash(Catalog));
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
using var reopened = Open();
var written = Assert.Single(reopened.GetAll<Loadout>());
Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value);
}
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.