From 7f1337167dd505bd103565d4f541fc5c6807ed12 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:32:11 +0200 Subject: [PATCH 1/5] 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 --- Assets/Scripts/ServerShared/Loadout.cs | 7 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 ++- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d7fec7f03..15e832a58 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -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); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..9e89411b8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -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)); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 7d3316302..c073bf37e 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -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(new CultRecordKey(key)); using var cache = Open(); var loadout = HandBuilt(cache); if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); - else loadout.Slots[1].Design = mod; - var before = Hash(Catalog); + else loadout.Slots[1].Design = new CultRecordRef(new CultRecordKey(key)); - var error = Assert.Throws(() => 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()); + 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. From 80283d18a9dd1cacd70eaf70f78b42105f6efc46 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:14 +0200 Subject: [PATCH 2/5] Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an internal overload over a caller's cache; tests pin that a faction changed on disk after the read refuses both, and that a missing boss faction writes nothing. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 34 ++++++++--- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 67 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -96,19 +96,28 @@ 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. - // 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) + // The only writer of Loadout records, and of the Faction.BossLoadout that names one. 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. 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. With bossOf set, the same batch makes the loadout that faction's boss + // (Faction.BossLoadout), so the loadout and the reference land together or not at all. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in + // it, 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) and on the faction, so a record changed on disk since this open + // is not clobbered: returns false then, and neither is written. + public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef bossOf = default) { 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); + return Commit(cache, loadout, replace, bossOf); + } + + // The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened. + internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) @@ -116,10 +125,17 @@ public static class Loadouts var existing = cache.Get(key); if (existing != null && !replace) throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); + var boss = bossOf.IsSet() + ? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.") + : null; return cache.Commit(batch => { batch.Expect(key, existing); batch.Upsert(typeof(Loadout), loadout, key); + if (boss == null) return; + batch.Expect(bossOf.Key, boss); + boss.BossLoadout = new CultRecordRef(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } + // With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key. + [Fact] + public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference() + { + using (var cache = Open()) + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("Maker"), out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + } + } + + // The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it, + // the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives. + [Fact] + public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference() + { + Loadout loadout; + CultRecordRef maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("Maker")); + } + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.NotNull(stale.Get(maker)); + using (var other = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var fresh = other.Get(maker); + Assert.True(other.Commit(batch => + { + batch.Expect(maker.Key, fresh); + fresh.ShortName = "NEW"; + batch.Upsert(typeof(Faction), fresh, maker.Key); + })); + } + + Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker)); + } + + using var after = Open(); + Assert.Empty(after.GetAll()); + var faction = after.Get(maker); + Assert.Equal("NEW", faction.ShortName); + Assert.False(faction.BossLoadout.IsSet()); + } + + // A boss faction the catalog does not hold is refused before anything is written. + [Fact] + public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten() + { + var before = Hash(Catalog); + using (var cache = Open()) + { + var error = Assert.Throws(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(new CultRecordKey("faction:absent")))); + Assert.Contains("boss faction is not in the catalog", error.Message); + } + Assert.Equal(before, Hash(Catalog)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() From 37de26291deb0755fce6ba21756a4b46a90c248c Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:28 +0200 Subject: [PATCH 3/5] AetherDb loadout-commit, with --boss-of, validated against the composed package stack Ported and narrowed from 90f977bf (evidence only; no boss-hull code). Names resolve exactly against ShipModCatalog.ResolveCatalog over GameData/Aetheria.cc and GameData/Mods, composed into a scratch file, the catalog the game boots; validation is one Loadouts.Materialize on a throwaway ItemManager; the commit goes into Aetheria.cc only, through Loadouts.Commit, with --boss-of in the same batch. Co-Authored-By: Claude Opus 5.5 --- .../LoadoutCommitCommandTests.cs | 278 ++++++++++++++++++ tools/AetherDb/LoadoutCommitCommand.cs | 153 ++++++++++ tools/AetherDb/Program.cs | 3 +- 3 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs create mode 100644 tools/AetherDb/LoadoutCommitCommand.cs diff --git a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs new file mode 100644 index 000000000..3ccf1720a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs @@ -0,0 +1,278 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's loadout-commit command (aetheria-release, cut loadout-commit; rulings boss-loadouts-agent-authored, +// catalog-is-a-package-stack), run on a scratch copy of the shipped catalog with a scratch GameData/Mods. Commit writes one +// Loadout at Loadouts.KeyOf(name) with the records its names resolve to in the composed catalog the game boots, and with +// --boss-of makes it that faction's boss in the same commit; every refusal leaves the catalog byte-identical. +public sealed class LoadoutCommitCommandTests : IDisposable +{ + private const string PackageId = "mod.loadoutcommit"; + private const string PackageProduct = "Loadout Commit Skiff Mk I"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "loadout-commit-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + private string Mods => Path.Combine(_root, "GameData", "Mods"); + + public LoadoutCommitCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + return cache; + } + + private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + // A hull product and a weapon slot of the shipped catalog that fit together, found the production way: a generated fit, + // captured. Every name in them is unique in the catalog, so the command resolves it exactly. + private (string hullProduct, string slot, string openSlot, string designName, string productName) Fit() + { + using var cache = Open(); + var settings = AuthoredSettings.Load(_root).Read("GameplaySettings"); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); + bool UniqueProduct(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + bool UniqueDesign(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship && h.Price > 0).OrderBy(h => h.Name, StringComparer.Ordinal)) + { + var random = new CultMath.Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + if (pack == null) continue; + var fit = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + var hullProduct = cache.Get(fit.HullProduct); + if (hullProduct == null || !UniqueProduct(hullProduct.Name)) continue; + foreach (var slot in fit.Slots.Where(s => s.Product.IsSet() && cache.Get(s.Design).Behaviors.Any(b => b is WeaponData))) + { + var design = cache.Get(slot.Design); + var product = cache.Get(slot.Product); + if (!UniqueDesign(design.Name) || !UniqueProduct(product.Name)) continue; + var cell = $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{design.Name}"; + return (hullProduct.Name, $"{cell}@{product.Name}", cell, design.Name, product.Name); + } + } + throw new InvalidOperationException("the shipped catalog has no ship hull with a uniquely named weapon fit"); + } + + // A package under the scratch Mods root whose ship.cc alone holds its hull and its one product, made by the first shipped + // faction by name: the shipped catalog holds neither. + private void WritePackage() + { + CultRecordKey maker; + using (var cache = Open()) + maker = cache.RefOf(cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).First()).Key; + var directory = ShipFixture.WritePackage(Mods, PackageId, "Loadout Commit Skiff", tweak: ship => ship.Hull.Price = 10); + using var store = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true); + ShipAuthoringStore.WriteProduct(store, PackageId, new FactionProductData + { + Name = PackageProduct, + Design = new CultRecordRef(ShipModCatalog.HullKey(PackageId)), + Manufacturer = new CultRecordRef(maker) + }); + store.FlushAsync().Wait(); + } + + private int Run(out string output, params string[] args) + { + var writer = new StringWriter(); + var code = LoadoutCommitCommand.Run(args, _root, writer); + output = writer.ToString(); + return code; + } + + [Fact] + public void No_arguments_print_the_usage_and_fail() + { + Assert.Equal(1, Run(out var output)); + Assert.StartsWith("usage: loadout-commit ", output); + Assert.Contains("--boss-of ", output); + } + + [Fact] + public void A_commit_writes_one_loadout_with_the_records_its_names_resolve_to() + { + var fit = Fit(); + Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--group", "0")); + Assert.StartsWith("Test boss: committed, hull ", output); + + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Test boss"), cache.RefOf(loadout).Key); + var product = cache.GetAll().Single(p => p.Name == fit.hullProduct); + Assert.Equal(cache.RefOf(product).Key, loadout.HullProduct.Key); + Assert.Equal(product.Design.Key, loadout.Hull.Key); + var slot = Assert.Single(loadout.Slots); + Assert.Equal(fit.designName, cache.Get(slot.Design).Name); + Assert.Equal(fit.productName, cache.Get(slot.Product).Name); + Assert.Equal(new[] { new[] { 0 } }, loadout.WeaponGroups); + Assert.DoesNotContain(cache.GetAll(), faction => faction.BossLoadout.IsSet() && faction.BossLoadout.Key.Equals(Loadouts.KeyOf("Test boss"))); + } + + // @product is optional: a design-only slot commits with no product. Replace overwrites only when asked. + [Fact] + public void A_design_only_slot_commits_with_no_product_and_replace_overwrites() + { + var fit = Fit(); + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot)); + var written = Hash(CatalogPath); + Assert.Equal(1, Run(out var refused, "Test boss", fit.hullProduct, fit.openSlot)); + Assert.Contains("already exists", refused); + Assert.Equal(written, Hash(CatalogPath)); + + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.openSlot, "--replace")); + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.False(Assert.Single(loadout.Slots).Product.IsSet()); + Assert.True(loadout.WeaponGroups == null || loadout.WeaponGroups.Length == 0); + } + + // Every refusal happens before Loadouts.Commit, so the catalog is byte-identical; none echoes the name it was given. + [Theory] + [InlineData("unknown-hull-product")] + [InlineData("ambiguous-hull-product")] + [InlineData("unknown-design")] + [InlineData("ambiguous-design")] + [InlineData("unknown-slot-product")] + [InlineData("rotation-not-a-name")] + [InlineData("rotation-a-number")] + [InlineData("slot-does-not-fit")] + [InlineData("group-names-no-slot")] + [InlineData("unknown-boss-faction")] + public void A_name_that_does_not_resolve_or_a_loadout_that_cannot_be_built_is_refused_and_nothing_is_written(string refusal) + { + var fit = Fit(); + var canary = "Canary9c1e"; + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + object Copy(object document) => MessagePack.MessagePackSerializer.Deserialize(document.GetType(), MessagePack.MessagePackSerializer.Serialize(document.GetType(), document, options), options); + using (var cache = Open()) + { + // Under a key of its own, so the copy is a second record of the same name, not the first overwritten. + void Add(object document) => Assert.True(cache.Commit(batch => batch.Upsert(document.GetType(), document, new CultRecordKey("duplicate:" + Guid.NewGuid().ToString("N"))))); + switch (refusal) + { + case "ambiguous-hull-product": + Add(Copy(cache.GetAll().Single(p => p.Name == fit.hullProduct))); + break; + case "ambiguous-design": + Add(Copy(cache.GetAll().Single(d => d.Name == fit.designName))); + break; + } + cache.FlushAsync().Wait(); + } + + var cell = fit.openSlot.Split(','); + var args = refusal switch + { + "unknown-hull-product" => new[] { "Test boss", canary, fit.slot }, + "unknown-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},{canary}" }, + "unknown-slot-product" => new[] { "Test boss", fit.hullProduct, $"{fit.openSlot}@{canary}" }, + "rotation-not-a-name" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{canary},{fit.designName}" }, + "rotation-a-number" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},1,{fit.designName}" }, + "slot-does-not-fit" => new[] { "Test boss", fit.hullProduct, $"999,999,{cell[2]},{fit.designName}" }, + "group-names-no-slot" => new[] { "Test boss", fit.hullProduct, fit.slot, "--group", "5" }, + "unknown-boss-faction" => new[] { "Test boss", fit.hullProduct, fit.slot, "--boss-of", canary }, + _ => new[] { "Test boss", fit.hullProduct, fit.slot } + }; + var before = Hash(CatalogPath); + + Assert.Equal(1, Run(out var output, args)); + + Assert.StartsWith("refused: ", output); + Assert.DoesNotContain(canary, output); + Assert.Equal(before, Hash(CatalogPath)); + using var after = Open(); + Assert.Empty(after.GetAll()); + } + + // Ruling catalog-is-a-package-stack: a hull product only a package under Mods holds resolves, because names resolve + // against the composed catalog the game boots, and the loadout is committed into the shipped file naming the package's keys. + // The shipped file gains the loadout and nothing of the package. + [Fact] + public void A_hull_product_held_only_by_a_package_resolves_and_commits() + { + WritePackage(); + using (var cache = Open()) + Assert.DoesNotContain(cache.GetAll(), p => p.Name == PackageProduct); + + Assert.Equal(0, Run(out var output, "Package boss", PackageProduct)); + Assert.StartsWith("Package boss: committed, hull Loadout Commit Skiff (Loadout Commit Skiff Mk I), 0 slots", output); + + using var after = Open(); + var loadout = Assert.Single(after.GetAll()); + Assert.Equal(ShipModCatalog.HullKey(PackageId), loadout.Hull.Key); + Assert.StartsWith("mod-product:" + PackageId + ":", loadout.HullProduct.Key.Value); + Assert.Null(after.Get(loadout.Hull)); + Assert.Null(after.Get(loadout.HullProduct)); + } + + // --boss-of, by Name or by ShortName, makes the loadout that faction's boss in the same commit; no other faction changes. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Boss_of_sets_the_factions_boss_loadout_in_the_same_commit(bool byShortName) + { + var fit = Fit(); + string factionName, argument; + CultRecordKey[] othersBefore; + using (var cache = Open()) + { + var faction = cache.GetAll().Where(f => !f.BossLoadout.IsSet()) + .Where(f => cache.GetAll().Count(o => o.Name == f.Name || o.ShortName == f.Name) == 1) + .Where(f => cache.GetAll().Count(o => o.Name == f.ShortName || o.ShortName == f.ShortName) == 1) + .OrderBy(f => f.Name, StringComparer.Ordinal).First(); + factionName = faction.Name; + argument = byShortName ? faction.ShortName : faction.Name; + othersBefore = cache.GetAll().Where(f => f != faction).OrderBy(f => f.Name, StringComparer.Ordinal).Select(f => f.BossLoadout.Key).ToArray(); + } + + Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--boss-of", argument)); + Assert.EndsWith($", boss of {factionName}", output.TrimEnd()); + + using var after = Open(); + var boss = after.GetAll().Single(f => f.Name == factionName); + var loadout = Loadouts.BossOf(after, boss, out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Test boss", loadout.Name); + Assert.Equal(othersBefore, after.GetAll().Where(f => f != boss).OrderBy(f => f.Name, StringComparer.Ordinal).Select(f => f.BossLoadout.Key).ToArray()); + } + + // A refused commit with --boss-of leaves the faction as it was: the loadout and the reference land together or not at all. + [Fact] + public void A_refused_boss_commit_leaves_the_faction_unchanged() + { + var fit = Fit(); + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot)); + string factionName; + CultRecordKey bossBefore; + using (var cache = Open()) + { + var faction = cache.GetAll().Where(f => cache.GetAll().Count(o => o.Name == f.Name || o.ShortName == f.Name) == 1) + .OrderBy(f => f.Name, StringComparer.Ordinal).First(); + factionName = faction.Name; + bossBefore = faction.BossLoadout.Key; + } + var before = Hash(CatalogPath); + + Assert.Equal(1, Run(out var output, "Test boss", fit.hullProduct, fit.openSlot, "--boss-of", factionName)); + + Assert.Contains("already exists", output); + Assert.Equal(before, Hash(CatalogPath)); + using var after = Open(); + Assert.Equal(bossBefore, after.GetAll().Single(f => f.Name == factionName).BossLoadout.Key); + } +} diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs new file mode 100644 index 000000000..46913b4c5 --- /dev/null +++ b/tools/AetherDb/LoadoutCommitCommand.cs @@ -0,0 +1,153 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; + +// loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ] +// (aetheria-release, cut loadout-commit; rulings boss-loadouts-agent-authored, catalog-is-a-package-stack): writes one Loadout +// record into GameData/Aetheria.cc through Loadouts.Commit, the only writer, and with --boss-of makes it that faction's boss in +// the same commit. Names resolve exactly against the catalog the game boots: ShipModCatalog.ResolveCatalog over the shipped +// file and GameData/Mods, composed into a scratch file that is never committed. So a loadout may name a package's hull, +// design or product (one hull product, one design per slot, one product where a slot names one, a rotation by its +// ItemRotation name, a faction by Name or ShortName). The loadout is then built once on a throwaway ItemManager with +// Loadouts.Materialize, so a slot that does not fit or a weapon group that names a non-weapon is refused here. Any refusal +// happens before Commit: the catalog stays byte-identical. A refusal names the field and the slot, never the value it was given. +public static class LoadoutCommitCommand +{ + private const string Usage = "usage: loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ]"; + + public static int Run(string[] args, string root = null, TextWriter output = null) + { + output ??= Console.Out; + try + { + return Commit(args, root, output); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException) + { + output.WriteLine($"refused: {e.Message}"); + return 1; + } + } + + private static int Commit(string[] args, string root, TextWriter output) + { + var replace = false; + string bossOfName = null; + var groups = new List(); + var positional = new List(); + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--replace") replace = true; + else if (args[i] == "--boss-of") + { + if (++i >= args.Length) throw new ArgumentException("--boss-of needs a faction"); + bossOfName = args[i]; + } + else if (args[i] == "--group") + { + if (++i >= args.Length) throw new ArgumentException("--group needs a list of slot indices"); + groups.Add(args[i].Split(',').Select(part => int.TryParse(part, out var index) ? index : throw new FormatException($"group {groups.Count}: an index is not a number")).ToArray()); + } + else positional.Add(args[i]); + } + + if (positional.Count < 2) + { + output.WriteLine(Usage); + return 1; + } + + root ??= AetherDb.FindRoot(); + var catalogPath = Path.Combine(root, "GameData", "Aetheria.cc"); + var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-loadout-commit-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var (composed, excluded) = ShipModCatalog.ResolveCatalog(catalogPath, Path.Combine(scratch, "derived.cc"), Path.Combine(root, "GameData", "Mods")); + foreach (var exclusion in excluded) output.WriteLine($"package left out, as at boot: {exclusion}"); + + Loadout loadout; + CultRecordRef bossOf = default; + string summary; + // Disposed before the commit: with no package composed, this is the shipped file itself. + using (var cache = AetheriaStores.Open(composed)) + { + loadout = Build(cache, positional[0], positional[1], positional.Skip(2).ToArray(), groups); + // Compose writes no faction, so the composed catalog's factions are the shipped ones under the same keys. + if (bossOfName != null) + bossOf = cache.RefOf(ExactlyOne(cache.GetAll().Where(f => f.Name == bossOfName || f.ShortName == bossOfName).ToArray(), "boss faction")); + + var failures = new List(); + var settings = AuthoredSettings.Load(root).Read("GameplaySettings"); + // Validation never draws for play, so the item stream gets a fixed seed. + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); + var entity = Loadouts.Materialize(itemManager, null, loadout, design => ProductsOf(cache, design).FirstOrDefault(), failures); + if (entity == null) throw new InvalidOperationException($"the loadout cannot be built: {string.Join("; ", failures)}"); + + summary = $"{loadout.Name}: committed, hull {cache.Get(loadout.Hull).Name} ({cache.Get(loadout.HullProduct).Name}), {loadout.Slots.Count} slots" + + (bossOf.IsSet() ? $", boss of {cache.Get(bossOf).Name}" : ""); + } + + if (!Loadouts.Commit(catalogPath, loadout, replace, bossOf)) + throw new InvalidOperationException("the catalog changed since it was read; nothing was written"); + output.WriteLine(summary); + return 0; + } + finally + { + Directory.Delete(scratch, true); + } + } + + // Validation only: a design-only slot is built at its first product by Name, so the throwaway build proves the fit. + private static IEnumerable ProductsOf(CultCache cache, CraftedItemData design) + { + var key = cache.RefOf(design).Key; + return cache.GetAll().Where(product => product.Design.Key.Equals(key)).OrderBy(product => product.Name, StringComparer.Ordinal); + } + + private static Loadout Build(CultCache cache, string name, string hullProductName, string[] slotArgs, List groups) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("the loadout name is empty"); + var hullProduct = ExactlyOne(cache.GetAll().Where(product => product.Name == hullProductName).ToArray(), "hull product"); + var hull = cache.Get(hullProduct.Design) as HullData ?? throw new InvalidOperationException("hull product: its design is not a hull"); + var slots = new List(); + for (var i = 0; i < slotArgs.Length; i++) + { + var where = $"slot {i}"; + var parts = slotArgs[i].Split(',', 4); + if (parts.Length != 4 || !int.TryParse(parts[0], out var x) || !int.TryParse(parts[1], out var y)) + throw new FormatException($"{where}: expected ,,,[@]"); + if (!Enum.TryParse(parts[2], out var rotation) || !Enum.IsDefined(typeof(ItemRotation), rotation) || int.TryParse(parts[2], out _)) + throw new FormatException($"{where}: rotation is not an ItemRotation name"); + var at = parts[3].IndexOf('@'); + var designName = at < 0 ? parts[3] : parts[3][..at]; + var design = ExactlyOne(cache.GetAll().Where(d => d.Name == designName).ToArray(), $"{where} design"); + var slot = new LoadoutSlot { Position = new int2(x, y), Rotation = rotation, Design = cache.RefOf(design) }; + if (at >= 0) + { + var productName = parts[3][(at + 1)..]; + slot.Product = cache.RefOf(ExactlyOne(cache.GetAll().Where(p => p.Name == productName).ToArray(), $"{where} product")); + } + slots.Add(slot); + } + + return new Loadout + { + Name = name, + Hull = cache.RefOf(hull), + HullProduct = cache.RefOf(hullProduct), + Slots = slots, + WeaponGroups = groups.Count == 0 ? null : groups.ToArray() + }; + } + + private static T ExactlyOne(T[] found, string field) => + found.Length == 1 ? found[0] : throw new InvalidOperationException(found.Length == 0 ? $"{field}: no record has that name" : $"{field}: more than one record has that name"); +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3e8b37b85..0f77c3436 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -26,6 +26,7 @@ public static class Program case "station-fit": return StationFit(); case "hardpoint-fit": return HardpointFit(); case "loadout": return Loadout(args.Skip(1).ToArray()); + case "loadout-commit": return LoadoutCommitCommand.Run(args.Skip(1).ToArray()); case "save": return Save(); case "factions": return Factions(); case "dangling": return Dangling(args.Skip(1).ToArray()); @@ -63,7 +64,7 @@ public static class Program return PlaceNamesCommand.Run(args.Skip(1).ToArray()); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); return 1; } } From 0e50c1d10124f3d6d0d2d8d13edd0135d7c4eebc Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:30 +0200 Subject: [PATCH 4/5] RunGoalTests: a boss Loadout on a removed package's hull still boots and fails closed Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8626148a5..880136d93 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -687,6 +687,47 @@ public sealed partial class RunStartTests foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3)); } + // Ruling catalog-is-a-package-stack: a boss Loadout committed on a package's hull, and the package then removed (the one + // uninstall). The catalog still resolves (the shipped file, nothing thrown), so the game boots; the Loadout and the + // faction's reference to it survive in the shipped file, and the boss zone fails closed with the reason ZoneGenerator logs. + [Fact] + public void A_boss_loadout_on_a_removed_package_hull_still_boots_and_leaves_the_zone_without_a_boss() + { + const string id = "mod.removedboss"; + var work = Path.Combine(_root, "removed-package"); + var shipped = Path.Combine(work, "GameData", "Aetheria.cc"); + var mods = Path.Combine(work, "GameData", "Mods"); + Directory.CreateDirectory(mods); + File.Copy(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), shipped); + ShipFixture.WritePackage(mods, id, "Removed Boss Hull"); + var installed = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "installed")).FullName); + Assert.NotEqual(shipped, installed); + + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var boss = new Loadout { Name = "Removed Package Boss", Hull = new CultRecordRef(ShipModCatalog.HullKey(id)), WeaponGroups = new int[0][] }; + Assert.True(Loadouts.Commit(shipped, boss, replace: false, _cache.RefOf(antagonist))); + ShipModCatalog.RemovePackage(mods, id); + + var booted = TestCatalog.Resolve(shipped, mods, Directory.CreateDirectory(Path.Combine(work, "booted")).FullName); + Assert.Equal(shipped, booted); + Loadout survived; + using (var catalog = AetheriaStores.Open(booted)) + { + survived = Loadouts.BossOf(catalog, catalog.GetAll().Single(f => f.Name == antagonist.Name), out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Null(catalog.Get(survived.Hull)); + } + + StoreLoadout(survived); + SetBoss(antagonist, survived); + var log = new List(); + var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("boss Loadout Removed Package Boss cannot be built") && line.Contains($"design {ShipModCatalog.HullKey(id)} is not in the catalog")); + } + // A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked. [Fact] public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss() From cdee1a27f82866c73e0bdd36417c5d8c58ceb760 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 03:43:55 +0200 Subject: [PATCH 5/5] LoadoutTests: a commit over a loadout written after the read writes nothing (kills the dropped-Expect mutant) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 6f55baa76..fca4ba339 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -196,6 +196,26 @@ public sealed class LoadoutTests : IDisposable Assert.False(faction.BossLoadout.IsSet()); } + // The commit is conditional on the record at KeyOf(Name) too: a loadout written under that name after this cache read the + // catalog is not overwritten, even with replace set, and the second commit reports false. + [Fact] + public void ACommitOverALoadoutWrittenAfterTheReadWritesNothing() + { + Loadout loadout; + using (var read = Open()) + loadout = HandBuilt(read); + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.Empty(stale.GetAll()); + Assert.True(Loadouts.Commit(Catalog, loadout, replace: false)); + Assert.False(Loadouts.Commit(stale, loadout, replace: true, default)); + } + + using var after = Open(); + Assert.Single(after.GetAll()); + } + // A boss faction the catalog does not hold is refused before anything is written. [Fact] public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten()