Aetheria/tests/Aetheria.Shared.Tests/LoadoutTests.cs

527 lines
25 KiB
C#
Raw Normal View History

Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
using Random = CultMath.Random;
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
// Loadouts are ship presets: item designs only, authored into the catalog, materialized all-or-nothing against it.
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
// The fixture hull is 3x3: InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay could use.
public sealed class LoadoutTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-loadout-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
private string Run => Path.Combine(_root, "run.cc");
private string Player => Path.Combine(_root, "player.cc");
private static readonly int2 HardpointCell = new int2(0, 0);
private static readonly int2 InteriorCell = new int2(1, 1);
public LoadoutTests()
{
Directory.CreateDirectory(_root);
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
var hullShape = new Shape(3, 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hull = cache.Upsert(new HullData
{
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
});
var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 });
var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 });
var gun = cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } });
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
cache.Upsert(new GearData { Name = "Orphan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1 });
foreach (var (name, design) in new[] { ("Skiff by Maker", hull.Key), ("Lamp by Maker", gear.Key), ("Crate by Maker", cargo.Key), ("Gun by Maker", gun.Key) })
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker });
cache.FlushAsync().Wait();
}
public void Dispose() => Directory.Delete(_root, true);
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
// The game's shape in every build, the editor included: a read-only catalog with the run and player stores.
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player);
// Gear on the hull's hardpoint cell, cargo on an interior cell, and two empty weapon groups (fewer than the game's six).
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout
{
Name = name,
Hull = cache.RefOf(cache.GetByName<HullData>("Skiff")),
Slots =
{
new LoadoutSlot { Position = HardpointCell, Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Lamp")) },
new LoadoutSlot { Position = InteriorCell, Design = cache.RefOf<EquippableItemData>(cache.GetByName<CargoBayData>("Crate")) }
},
WeaponGroups = new[] { new int[0], new int[0] }
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
};
// HandBuilt with the Gun on the hardpoint, alone in the first of the game's six weapon groups.
private static Loadout Armed(CultCache cache, string name = "Armed build")
{
var armed = HandBuilt(cache, name);
armed.Slots[0].Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Gun"));
armed.WeaponGroups = Enumerable.Range(0, 6).Select(g => g == 0 ? new[] { 0 } : new int[0]).ToArray();
return armed;
}
private static Loadout WithDesign(Loadout loadout, int slot, CultCache cache, string gear)
{
loadout.Slots[slot].Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>(gear));
return loadout;
}
// Capture from a built ship, commit to the catalog, reopen fresh read-only: the preset and the ship it builds match.
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
[Fact]
public void CapturedPresetRoundTripsThroughTheCatalog()
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
{
Loadout hand;
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
using (var cache = Open())
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
{
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
hand = Armed(cache);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
var failures = new List<string>();
var ship = Build(items, hand, failures);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
Assert.Empty(failures);
Assert.NotNull(ship);
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
Assert.True(Loadouts.Commit(Catalog, Loadouts.Capture(items, ship, hand.Name), replace: false));
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
}
using (var cache = Open())
{
var loaded = Assert.Single(cache.GetAll<Loadout>());
Assert.Equal(Loadouts.KeyOf(hand.Name), cache.RefOf(loaded).Key);
Assert.Equal(hand.Name, loaded.Name);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
Assert.Equal(hand.Hull.Key, loaded.Hull.Key);
Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe));
Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups);
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
var failures = new List<string>();
var ship = Build(items, loaded, failures);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
Assert.Empty(failures);
Assert.Equal(hand.Slots.Select(slot => (slot.Position, slot.Design.Key)),
ship.Equipment.Concat<EquippedItem>(ship.CargoBays)
.Where(item => item != ship.EquippedHull)
.Select(item => (item.Position, cache.RefOf(items.GetData(item.EquippableItem)).Key)));
}
Assert.Single(SchemaNames(Catalog), name => name == "aetheria.loadout");
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
}
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
[Fact]
public void SameNameCaptureReplacesOnlyWhenAsked()
{
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
using (var cache = Open())
{
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
var before = Hash(Catalog);
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: false));
Assert.Contains("Skiff build", error.Message);
Assert.Equal(before, Hash(Catalog));
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
Assert.True(Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: true));
}
using (var cache = Open())
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
}
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
// Play mutates live catalog instances; a preset commit must land only itself.
[Fact]
public void PresetCommitWritesNoOtherInMemoryCatalogState()
{
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
using (var cache = Open())
{
cache.GetByName<Faction>("Maker").ShortName = "XXX";
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
cache.FlushAsync().Wait();
}
using (var cache = Open())
{
Assert.Equal("MKR", cache.GetByName<Faction>("Maker").ShortName);
Assert.Single(cache.GetAll<Loadout>());
}
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
}
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
// The process cache holds the catalog read-only while capture writes through its own cache: the capture lands, and
// the process cache keeps its instances, gains nothing and stays clean until it reloads.
[Fact]
public void CaptureWhileTheCatalogIsHeldReadOnlyLeavesTheProcessCacheUnchanged()
{
using (var cache = Open())
{
var maker = cache.GetByName<Faction>("Maker");
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
Assert.Empty(cache.GetAll<Loadout>());
Assert.Same(maker, cache.GetByName<Faction>("Maker"));
Assert.False(cache.IsDirty);
Assert.Throws<InvalidOperationException>(() => cache.Upsert(new Faction { Name = "Usurper" }));
}
using (var cache = Open())
Assert.Equal("Skiff build", Assert.Single(cache.GetAll<Loadout>()).Name);
}
// Capture never creates a catalog: a missing file refuses, and no file or directory appears.
[Fact]
public void CaptureWithAMissingCatalogRefusesAndCreatesNothing()
{
var absent = Path.Combine(_root, "absent", "Aetheria.cc");
using var cache = Open();
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(absent, HandBuilt(cache), replace: false));
Assert.Contains("does not exist", error.Message);
Assert.False(Directory.Exists(Path.GetDirectoryName(absent)));
}
// A preset authored in Studio has a minted key; capturing its name refuses, replace or not, and names that key.
[Fact]
public void SameNameUnderAnotherKeyRefuses()
{
using (var writer = AetheriaStores.Open(Catalog, catalogWritable: true))
{
writer.Upsert(HandBuilt(writer));
writer.FlushAsync().Wait();
}
var before = Hash(Catalog);
using (var cache = Open())
{
var studioKey = cache.RefOf(Assert.Single(cache.GetAll<Loadout>())).Key;
Assert.NotEqual(Loadouts.KeyOf("Skiff build"), studioKey);
foreach (var replace in new[] { false, true })
{
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace));
Assert.Contains(studioKey.Value, error.Message);
}
}
Assert.Equal(before, Hash(Catalog));
}
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
[Fact]
public void LoadoutHoldsNoRunOrGalaxyRefs()
{
var forbidden = new[] { typeof(ItemInstance), typeof(EntityPack), typeof(FactionProductData), typeof(Faction) }
.Concat(AetheriaStores.RunTypes)
.ToArray();
var seen = new HashSet<Type>();
var pending = new Queue<Type>(new[] { typeof(Loadout) });
while (pending.Count > 0)
{
var type = pending.Dequeue();
if (!seen.Add(type)) continue;
Assert.DoesNotContain(forbidden, bad => bad.IsAssignableFrom(type));
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
{
Assert.True(typeof(EquippableItemData).IsAssignableFrom(type.GetGenericArguments()[0]), $"{type} names a non-design record");
continue;
}
if (type.IsArray) pending.Enqueue(type.GetElementType());
foreach (var argument in type.IsGenericType ? type.GetGenericArguments() : Type.EmptyTypes) pending.Enqueue(argument);
if (type.Assembly == typeof(Loadout).Assembly)
foreach (var field in type.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
pending.Enqueue(field.FieldType);
}
Capture presets through their own cache; keep the catalog read-only Opening the process cache's catalog writable in the editor was the wrong cut. It made catalog read-only only by convention, so a stray catalog write in play succeeded and a later flush could land play-mutated instances in Aetheria.cc. - The process cache opens the catalog read-only in every build again, so the missing-global check runs for it as it did before db83cd29. The writable empty-seed exemption from ab4ced0f stays for AetherDb and fixtures. - Loadouts.Commit(catalogPath, loadout, replace) is the only preset writer. It opens its own short-lived cache over the catalog, writable, commits the one preset conditionally on its key and disposes it. No other cache or instance is touched. The running game sees the preset only after the catalog reloads. CultLib at a0813c6 allows this: a read-only single-file store takes no lock and holds no handle, and the writer's commit takes the exclusive sidecar <path>.lock and replaces the file atomically. - Capture refuses, writing nothing, when Aetheria.cc is missing (it never creates the catalog), when a preset of that name exists under another key (CultCache does not reject duplicate names on write), or when the key holds a preset and replace is not given. - The console refuses a command with any character outside letters, digits, spaces and hyphens instead of stripping it, so "Mk.II" can no longer become "MkII". capturepreset refuses a second argument other than replace, so an unquoted capturepreset My Ship refuses instead of saving "My". - The capture log line says the session sees the preset after a reload, and that a Studio session opened earlier overwrites captured presets on save. - Tests: capture while the process cache holds the catalog read-only leaves it unchanged; a missing catalog refuses and creates nothing; a same name under a Studio-minted key refuses, with or without replace; a game-shaped read-only open missing a global throws. The existing preset tests now commit through the catalog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:32:52 +02:00
using (var cache = Open())
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
Assert.Contains("aetheria.loadout", SchemaNames(Catalog));
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0);
Assert.True(!File.Exists(Player) || SchemaNames(Player).Length == 0);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
}
[Fact]
public void MissingDesignReports()
{
using (var cache = Open())
cache.Commit(batch => batch.Upsert(new PlayerSettings()));
var before = new[] { Catalog, Run, Player }.Select(Hash).ToArray();
using (var cache = Open())
{
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
var missing = HandBuilt(cache);
missing.Slots[1].Design = new CultRecordRef<EquippableItemData>(new CultRecordKey("absent-design"));
var failures = new List<string>();
Assert.Null(Build(items, missing, failures));
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
var failure = Assert.Single(failures);
Assert.Contains("1,1", failure);
Assert.Contains("absent-design", failure);
missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Orphan")) });
failures.Clear();
Assert.Null(Build(items, missing, failures));
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
Assert.Equal(2, failures.Count);
Assert.Contains("slot 1,1", failures[0]);
Assert.Contains("slot 2,2", failures[1]);
Assert.Contains("no available product of Orphan", failures[1]);
}
Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray());
}
[Fact]
public void FailedFitBuildsNothing()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var clash = HandBuilt(cache);
clash.Slots[1].Position = HardpointCell;
var failures = new List<string>();
Assert.Null(Build(items, clash, failures));
var failure = Assert.Single(failures);
Assert.Contains("slot 0,0: Crate does not fit", failure);
}
// Two new makers, each with one Lamp product, upserted into the live cache so its enumeration order (z before a)
// disagrees with record-key order; a reopened snapshot could already hand them back sorted. Brand() requires at
// most one product per (maker, design), so each gets its own maker rather than sharing "Maker".
[Fact]
public void FirstAvailableProductInKeyOrderBuildsTheSlot()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var lamp = new CultRecordRef<CraftedItemData>(cache.RefOf(cache.GetByName<GearData>("Lamp")).Key);
var zMaker = cache.Upsert(new Faction { Name = "Zeta", ShortName = "ZET" });
var aMaker = cache.Upsert(new Faction { Name = "Alpha", ShortName = "ALP" });
cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-z", Design = lamp, Manufacturer = zMaker }, new CultRecordKey("lamp-z")));
cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { Name = "lamp-a", Design = lamp, Manufacturer = aMaker }, new CultRecordKey("lamp-a")));
Assert.Equal(new[] { "lamp-z", "lamp-a" }, cache.GetAll<FactionProductData>().Select(p => p.Name).Where(name => name.StartsWith("lamp-")));
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var hand = HandBuilt(cache);
string LampProduct(Predicate<FactionProductData> available, List<string> failures)
{
var ship = Loadouts.Materialize(items, null, hand, available, failures);
if (ship == null) return null;
var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem;
return items.Brand(unit).Product.Name;
}
var failures = new List<string>();
Assert.Equal("lamp-a", LampProduct(p => p.Name != "Lamp by Maker", failures));
Assert.Equal("lamp-z", LampProduct(p => p.Name != "Lamp by Maker" && p.Name != "lamp-a", failures));
Assert.Empty(failures);
Assert.Null(LampProduct(p => !p.Name.StartsWith("Lamp") && !p.Name.StartsWith("lamp"), failures));
Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures));
}
// Materialize a hand-built ship, pack it into a zone, commit through Open() and reopen: every crafted instance
// still resolves in RunSave.Lots, its Data still equals the lot's Design, the origin is Attributed with the
// product's maker, and Quality is unchanged from the lot minted before the save.
[Fact]
public void MaterializedLotsSurviveSaveAndReload()
{
Dictionary<int, float> preSaveQuality;
CultRecordKey maker;
using (var cache = Open())
{
maker = cache.RefOf(cache.GetByName<Faction>("Maker")).Key;
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var hand = HandBuilt(cache);
var failures = new List<string>();
var ship = Build(items, hand, failures);
Assert.Empty(failures);
preSaveQuality = items.Lots.Lots.ToDictionary(kv => kv.Key, kv => kv.Value.Quality);
var pack = EntitySerializer.Pack(ship);
var game = new SavedGame
{
Factions = Array.Empty<CultRecordRef<Faction>>(),
Relationships = Array.Empty<FactionRelationship>(),
HomeZones = new Dictionary<int, int>(),
BossZones = new Dictionary<int, int>(),
DiscoveredZones = new[] { 0 },
ActionBarBindings = new SavedActionBarBinding[0],
Exit = -1
};
var zones = new[]
{
new SavedZone
{
Name = "Zone 0", AdjacentZones = Array.Empty<int>(), Factions = Array.Empty<int>(), Owner = -1,
Contents = new ZonePack { Entities = new List<EntityPack> { pack } }
}
};
RunSave.Commit(cache, game, zones, items.Lots);
}
using (var cache = Open())
{
var lots = RunSave.Lots(cache);
var run = cache.GetGlobal<SavedGame>();
var zone = cache.Get(run.Zones[0]);
var crafted = EntitySerializer.Items(zone.Contents.Entities[0]).OfType<CraftedItemInstance>().ToArray();
Assert.NotEmpty(crafted);
foreach (var instance in crafted)
{
var lot = lots[instance.Lot];
Assert.Equal(instance.Data.Key, lot.Design.Key);
var attributed = Assert.IsType<Attributed>(lot.Origin);
Assert.Equal(maker, attributed.Faction.Key);
Assert.Equal(preSaveQuality[instance.Lot], lot.Quality);
}
}
}
// FillInterior requires a capacitor product; this fixture hull adds one alongside a 4x4 hull with two identical
// Sensors hardpoints.
[Fact]
public void MatchingHardpointsShareALot()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
var hullShape = new Shape(4, 4);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hull = cache.Upsert(new HullData
{
Name = "Twin", HullType = HullType.Ship, Shape = hullShape, Price = 100,
Hardpoints =
{
new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() },
new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() }
}
});
var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } });
foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key) })
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker });
cache.FlushAsync().Wait();
var random = new Random(1);
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Twin");
Assert.NotNull(pack);
var hardpointItems = pack.Equipment
.Where(e => (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Sensors)
.ToArray();
Assert.Equal(2, hardpointItems.Length);
Assert.Equal(hardpointItems[0].item.Lot, hardpointItems[1].item.Lot);
}
// A Lamp lot with Quality .2 and one role fill (lens .9): a stat reading that role gets .9, one reading none
// gets .2, and GetTier reads .2.
[Fact]
public void StatsReadTheLot()
{
using var cache = Open();
var settings = RunSaveTests.TestSettings();
settings.Tiers = new[] { new RarityTier { Name = "Common", Quality = .2f, Rarity = 0, Color = new float3(1, 1, 1) } };
var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { });
var lamp = cache.GetByName<GearData>("Lamp");
var lotId = items.Lots.Add(new Lot
{
Design = cache.RefOf<ItemData>(lamp),
Origin = new Attributed(),
Quality = .2f,
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .9f } }
});
var instance = (EquippableItem) items.CreateInstance(lotId);
var withRole = new PerformanceStat { FromRole = "lens", Min = 0, Max = 1, QualityExponent = 1 };
var noRole = new PerformanceStat { Min = 0, Max = 1, QualityExponent = 1 };
Assert.Equal(.9f, items.Evaluate(withRole, instance), 3);
Assert.Equal(.2f, items.Evaluate(noRole, instance), 3);
Assert.Equal(.2f, items.GetTier(instance).tier.Quality, 3);
}
[Fact]
public void OutOfRangeWeaponGroupIndexBuildsNothing()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var stale = Armed(cache);
stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } };
var failures = new List<string>();
Assert.Null(Build(items, stale, failures));
Assert.Equal(new[] { "weapon group: no slot 2", "weapon group: no slot -1" }, failures);
}
[Fact]
public void UnequippingAGroupedWeaponRemovesItFromItsGroups()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var ship = Build(items, Armed(cache), new List<string>());
var item = Assert.Single(ship.WeaponGroups[0].items);
Assert.NotNull(item.GetBehavior<Weapon>());
Assert.NotNull(ship.TryUnequip(item));
Assert.All(ship.WeaponGroups, group => Assert.Empty(group.items));
Assert.All(ship.WeaponGroups, group => Assert.Empty(group.weapons));
}
// A group index on a non-weapon slot would put a null weapon in the group; it is refused before anything is built.
[Fact]
public void NonWeaponGroupIndexBuildsNothing()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp");
var failures = new List<string>();
Assert.Null(Build(items, lampGrouped, failures));
Assert.Equal("weapon group: slot 0,0: Lamp is not a weapon", Assert.Single(failures));
}
[Fact]
public void MoreWeaponGroupsThanTheGameHasBuildsNothing()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var wide = Armed(cache);
wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray();
var failures = new List<string>();
Assert.Null(Build(items, wide, failures));
Assert.Equal("weapon groups: 7 groups, at most 6", Assert.Single(failures));
}
// Fewer groups, or none, still give the ship exactly WeaponGroupCount groups, so GenerateWeaponGroups and G1-G6
// bindings can index every one.
[Fact]
public void FewerWeaponGroupsArePaddedToTheGameCount()
{
using var cache = Open();
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
var armed = Armed(cache);
armed.WeaponGroups = new[] { new[] { 0 } };
foreach (var groups in new[] { armed.WeaponGroups, null })
{
armed.WeaponGroups = groups;
var failures = new List<string>();
var ship = Build(items, armed, failures);
Assert.Empty(failures);
Assert.Equal(6, ship.WeaponGroups.Length);
Assert.All(ship.WeaponGroups.Skip(1), group => Assert.Empty(group.items));
Assert.Equal(groups == null ? 0 : 1, ship.WeaponGroups[0].items.Count);
ship.GenerateWeaponGroups();
}
}
// Everything available: for tests about placement and failure lists.
private static Ship Build(ItemManager items, Loadout loadout, List<string> failures) =>
Loadouts.Materialize(items, null, loadout, _ => true, failures);
Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:06:06 +02:00
private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);
private static string Hash(string path) => File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : "absent";
private static string[] SchemaNames(string path)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
return snapshot.Records
.Select(record => snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName)
.ToArray();
}
}