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 ;
2026-09-17 04:17:13 +02:00
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
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +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 } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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 } ) ;
Pin the targeting subsystem, reveal rule and Q4's required-equipment rule
TargetingSystemTests: selection needs a real reveal (and drops it again
on decay, with no clearing loop), the reveal ranking (hardpoint before
interior, larger before smaller), the two thresholds are the ones
actually read, unaided fire falls back correctly for no/destroyed/
disabled systems, a starved system's Accuracy halves with the grant
(a real Reactor + PowerBus tick, since PowerSupply's setter is internal
to a different assembly here), and every armed generated loadout gets a
targeting system across ten seeds.
LoadoutTests' shared fixture now authors a targeting-system product too:
its "Twin" hull can randomly draw the weapon-carrying "Gun" onto its
Sensors hardpoint, and FillInterior's new required item would otherwise
throw for a fixture that predates this cut.
174 tests before this cut; 180 after (174 unchanged + 6 new), all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:03:12 +02:00
// Cut 2 (docs/fire-control-cut.md, Q4): required equipment for any entity with a weapon
// (LoadoutGenerator.FillInterior). "Gun" carries a weapon behaviour and shares this catalog's one
// Sensors hardpoint type with "Lamp"/"Orphan", so any generated loadout that happens to draw Gun
// needs a targeting-system product available or FillInterior throws.
var targeting = cache . Upsert ( new GearData { Name = "Scope" , Hardpoint = HardpointType . Tool , Shape = new Shape ( ) , Price = 1 , Behaviors = { new TargetingSystemData ( ) } } ) ;
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
Pin the targeting subsystem, reveal rule and Q4's required-equipment rule
TargetingSystemTests: selection needs a real reveal (and drops it again
on decay, with no clearing loop), the reveal ranking (hardpoint before
interior, larger before smaller), the two thresholds are the ones
actually read, unaided fire falls back correctly for no/destroyed/
disabled systems, a starved system's Accuracy halves with the grant
(a real Reactor + PowerBus tick, since PowerSupply's setter is internal
to a different assembly here), and every armed generated loadout gets a
targeting system across ten seeds.
LoadoutTests' shared fixture now authors a targeting-system product too:
its "Twin" hull can randomly draw the weapon-carrying "Gun" onto its
Sensors hardpoint, and FillInterior's new required item would otherwise
throw for a fixture that predates this cut.
174 tests before this cut; 180 after (174 unchanged + 6 new), all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:03:12 +02:00
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 ) , ( "Scope by Maker" , targeting . 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 ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
// 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" ) ) }
} ,
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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
} ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
// HandBuilt with the Gun on the hardpoint, alone in the first of the game's six weapon groups.
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
private static Loadout Armed ( CultCache cache , string name = "Armed build" )
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
{
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
var armed = HandBuilt ( cache , name ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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 ;
}
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
// 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]
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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
{
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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 > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ( ) )
{
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ;
2026-09-17 04:17:13 +02:00
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 > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ) ) ;
}
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Assert . Single ( SchemaNames ( Catalog ) , name = > name = = "aetheria.loadout" ) ;
Assert . True ( ! File . Exists ( Player ) | | ! SchemaNames ( Player ) . Contains ( "aetheria.loadout" ) ) ;
}
Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot
- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:30:52 +02:00
// The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The
// refusal is at the only writer, so no caller (the editor command included) can get one into the file.
[Theory]
[InlineData("hull", "mod-hull:mod.skiff")]
[InlineData("slot", "mod-hull:mod.skiff")]
[InlineData("slot", "mod-ship:mod.skiff")]
public void APresetNamingAModDesignIsRefusedAndNothingIsWritten ( string where , string key )
{
var mod = new CultRecordRef < EquippableItemData > ( new CultRecordKey ( key ) ) ;
using var cache = Open ( ) ;
var loadout = HandBuilt ( cache ) ;
if ( where = = "hull" ) loadout . Hull = new CultRecordRef < HullData > ( new CultRecordKey ( key ) ) ;
else loadout . Slots [ 1 ] . Design = mod ;
var before = Hash ( Catalog ) ;
var error = Assert . Throws < InvalidOperationException > ( ( ) = > Loadouts . Commit ( Catalog , loadout , replace : false ) ) ;
Assert . Contains ( "names mod designs" , error . Message ) ;
Assert . Contains ( key , error . Message ) ;
Assert . Equal ( before , Hash ( Catalog ) ) ;
Assert . True ( Loadouts . Commit ( Catalog , HandBuilt ( cache ) , replace : false ) ) ;
}
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
// 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 ( ) )
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +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
Assert . True ( Loadouts . Commit ( Catalog , HandBuilt ( cache ) , replace : false ) ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
}
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.
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
[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 ( ) )
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
{
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 ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
}
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 ) ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ( ) )
{
2026-09-17 04:17:13 +02:00
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 > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ) ;
2026-09-30 12:33:00 +02:00
missing . Slots . Add ( new LoadoutSlot { Position = new int2 ( 2 , 2 ) , Design = new CultRecordRef < EquippableItemData > ( new CultRecordKey ( "another-absent-design" ) ) } ) ;
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
failures . Clear ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
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 ] ) ;
2026-09-30 12:33:00 +02:00
Assert . Contains ( "another-absent-design" , failures [ 1 ] ) ;
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 ( before , new [ ] { Catalog , Run , Player } . Select ( Hash ) . ToArray ( ) ) ;
}
2026-09-14 22:27:03 +02:00
[Fact]
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
public void FailedFitBuildsNothing ( )
2026-09-14 22:27:03 +02:00
{
using var cache = Open ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
2026-09-14 22:27:03 +02:00
var clash = HandBuilt ( cache ) ;
clash . Slots [ 1 ] . Position = HardpointCell ;
var failures = new List < string > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Assert . Null ( Build ( items , clash , failures ) ) ;
2026-09-14 22:27:03 +02:00
var failure = Assert . Single ( failures ) ;
Assert . Contains ( "slot 0,0: Crate does not fit" , failure ) ;
}
2026-09-17 04:17:13 +02:00
// Two new makers, each with one Lamp product, upserted into the live cache so its enumeration order (z before a)
2026-10-08 16:45:56 +02:00
// disagrees with record-key order; a reopened snapshot could already hand them back sorted. Each gets its own
// maker rather than sharing "Maker".
2026-09-14 22:27:03 +02:00
[Fact]
public void FirstAvailableProductInKeyOrderBuildsTheSlot ( )
{
2026-09-14 22:35:16 +02:00
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var lamp = new CultRecordRef < CraftedItemData > ( cache . RefOf ( cache . GetByName < GearData > ( "Lamp" ) ) . Key ) ;
2026-09-17 04:17:13 +02:00
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" ) ) ) ;
2026-09-14 22:35:16 +02:00
Assert . Equal ( new [ ] { "lamp-z" , "lamp-a" } , cache . GetAll < FactionProductData > ( ) . Select ( p = > p . Name ) . Where ( name = > name . StartsWith ( "lamp-" ) ) ) ;
2026-09-14 22:27:03 +02:00
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
2026-09-14 22:27:03 +02:00
var hand = HandBuilt ( cache ) ;
string LampProduct ( Predicate < FactionProductData > available , List < string > failures )
{
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
var ship = Loadouts . Materialize ( items , null , hand , available , failures ) ;
2026-09-17 04:17:13 +02:00
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 ;
2026-09-14 22:27:03 +02:00
}
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 ) ) ;
}
2026-09-30 12:33:00 +02:00
// Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it
// unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog.
[Fact]
public void ADesignWithNoProductBuildsUnbranded ( )
{
using var cache = Open ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var failures = new List < string > ( ) ;
var ship = Build ( items , WithDesign ( HandBuilt ( cache ) , 0 , cache , "Orphan" ) , failures ) ;
Assert . Empty ( failures ) ;
var unit = ship . Equipment . Single ( item = > item . EquippableItem ! = ship . Hull & & item . Position . Equals ( HardpointCell ) ) . EquippableItem ;
Assert . Equal ( "Orphan" , items . GetData ( unit ) . Name ) ;
Assert . Null ( items . Brand ( unit ) . Maker ) ;
Assert . Equal ( Loadouts . UnbrandedQuality , items . GetLot ( unit ) . Quality ) ;
}
// The existing contract Q3 does not loosen: a design that has a product, none of them available, is not built,
// unbranded or otherwise.
[Fact]
public void ADesignWhoseOnlyProductIsUnavailableStillFails ( )
{
using var cache = Open ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var failures = new List < string > ( ) ;
Assert . Null ( Loadouts . Materialize ( items , null , HandBuilt ( cache ) , p = > p . Name ! = "Lamp by Maker" , failures ) ) ;
Assert . Equal ( "slot 0,0: no available product of Lamp" , Assert . Single ( failures ) ) ;
2026-09-17 12:42:48 +02:00
}
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
// F2: this test must not enumerate reloaded instances through EntitySerializer.Items, the GC root walk under
// test. It hand-mints one real lot per EntityPack root (Hull, Equipment, CargoBays, DockingBays, CargoContents,
// Children, DockingBayContents) so the expected lot ids are known independently of any enumerator, and reads
// the reloaded pack's own fields directly. Skipping any one root in Items/Reachable drops that root's lot from
// the stored ledger, which RunSave.Lots' indexer throws loudly on.
2026-09-17 04:17:13 +02:00
[Fact]
public void MaterializedLotsSurviveSaveAndReload ( )
{
CultRecordKey maker ;
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
int [ ] expectedLots ;
using ( var cache = AetheriaStores . Open ( Catalog , Run , Player , catalogWritable : true ) )
2026-09-17 04:17:13 +02:00
{
maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) . Key ;
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
var dockingBay = cache . Upsert ( new DockingBayData { Name = "Bay" , Shape = new Shape ( ) , InteriorShape = new Shape ( ) , Price = 20 } ) ;
cache . Upsert ( new FactionProductData { Name = "Bay by Maker" , Design = new CultRecordRef < CraftedItemData > ( dockingBay . Key ) , Manufacturer = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var skiff = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Skiff by Maker" ) ;
var lamp = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Lamp by Maker" ) ;
var crate = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Crate by Maker" ) ;
var gun = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Gun by Maker" ) ;
var bay = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Bay by Maker" ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
EquippableItem Mint ( FactionProductData product ) = > ( EquippableItem ) items . CreateInstance ( product ) ;
var hull = Mint ( skiff ) ;
var equipment = Mint ( lamp ) ;
var cargoBay = Mint ( crate ) ;
var dockingBayUnit = Mint ( bay ) ;
var cargoContent = Mint ( gun ) ;
var dockingBayContent = Mint ( lamp ) ;
var childHull = Mint ( skiff ) ;
var childPack = new ShipPack
{
Name = "child" , Hull = childHull ,
Equipment = Array . Empty < ( int2 , EquippableItem ) > ( ) , CargoBays = Array . Empty < ( int2 , EquippableItem ) > ( ) ,
DockingBays = Array . Empty < ( int2 , EquippableItem ) > ( ) , CargoContents = Array . Empty < ( int2 , ItemInstance ) [ ] > ( ) ,
DockingBayContents = Array . Empty < ( int2 , ItemInstance ) [ ] > ( ) , Children = Array . Empty < EntityPack > ( ) ,
2026-09-17 05:44:37 +02:00
Temperature = new float [ 0 , 0 ] ,
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
Armor = new float [ 0 , 0 ] , Conductivity = new bool2 [ 0 , 0 ] , DockingBayAssignments = Array . Empty < int > ( ) ,
Settings = new EntitySettings ( ) , WeaponGroups = Array . Empty < int [ ] > ( )
} ;
var rootPack = new ShipPack
{
Name = "root" , Hull = hull ,
Equipment = new [ ] { ( default ( int2 ) , equipment ) } ,
CargoBays = new [ ] { ( default ( int2 ) , cargoBay ) } ,
DockingBays = new [ ] { ( default ( int2 ) , dockingBayUnit ) } ,
CargoContents = new [ ] { new [ ] { ( default ( int2 ) , ( ItemInstance ) cargoContent ) } } ,
DockingBayContents = new [ ] { new [ ] { ( default ( int2 ) , ( ItemInstance ) dockingBayContent ) } } ,
Children = new EntityPack [ ] { childPack } ,
DockingBayAssignments = new [ ] { 0 } ,
2026-09-17 05:44:37 +02:00
Temperature = new float [ 0 , 0 ] ,
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
Armor = new float [ 0 , 0 ] , Conductivity = new bool2 [ 0 , 0 ] ,
Settings = new EntitySettings ( ) , WeaponGroups = Array . Empty < int [ ] > ( )
} ;
expectedLots = new [ ]
{
hull . Lot , equipment . Lot , cargoBay . Lot , dockingBayUnit . Lot , cargoContent . Lot , dockingBayContent . Lot , childHull . Lot
} ;
2026-09-17 04:17:13 +02:00
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 ,
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
Contents = new ZonePack { Entities = new List < EntityPack > { rootPack } }
2026-09-17 04:17:13 +02:00
}
} ;
RunSave . Commit ( cache , game , zones , items . Lots ) ;
}
using ( var cache = Open ( ) )
{
var run = cache . GetGlobal < SavedGame > ( ) ;
var zone = cache . Get ( run . Zones [ 0 ] ) ;
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
var reloaded = ( ShipPack ) zone . Contents . Entities [ 0 ] ;
var reloadedChild = ( ShipPack ) reloaded . Children [ 0 ] ;
var lots = RunSave . Lots ( cache ) ;
Assert . Equal ( expectedLots . OrderBy ( l = > l ) , new [ ]
{
reloaded . Hull . Lot , reloaded . Equipment [ 0 ] . item . Lot , reloaded . CargoBays [ 0 ] . item . Lot ,
reloaded . DockingBays [ 0 ] . item . Lot , ( ( CraftedItemInstance ) reloaded . CargoContents [ 0 ] [ 0 ] . item ) . Lot ,
( ( CraftedItemInstance ) reloaded . DockingBayContents [ 0 ] [ 0 ] . item ) . Lot , reloadedChild . Hull . Lot
} . OrderBy ( l = > l ) ) ;
foreach ( var lotId in expectedLots )
2026-09-17 04:17:13 +02:00
{
Fix F2: stop verifying the GC walk with itself in MaterializedLotsSurviveSaveAndReload
The test read reloaded instances through EntitySerializer.Items, the same
enumerator RunSave.Commit uses to compute GC roots. Dropping a root from
Items would silently agree with itself: the root's lot would never be
written, and the test would never look for it.
Rewritten to hand-mint one real lot per EntityPack root (Hull, Equipment,
CargoBays, DockingBays, CargoContents, Children, DockingBayContents)
through ItemManager, so the expected lot ids are known independently of
any enumerator, then read the reloaded pack's own fields directly.
Verified: reverting Items to skip the Equipment root now fails this test
with "Lot 2 is not in the provenance ledger" (previously green). Reverted
after confirming the kill; 36 of 36 tests pass on the real fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:02:48 +02:00
var lot = lots [ lotId ] ;
2026-09-17 04:17:13 +02:00
var attributed = Assert . IsType < Attributed > ( lot . Origin ) ;
Assert . Equal ( maker , attributed . Faction . Key ) ;
}
}
}
// 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 ( ) ;
2026-09-17 05:05:09 +02:00
// Two tiers with distinct quality thresholds: a single-tier fixture cannot fail "GetTier ignores lot
// quality", since every lot would resolve to the only tier regardless of its own Quality.
settings . Tiers = new [ ]
{
new RarityTier { Name = "Common" , Quality = . 2f , Rarity = 0 , Color = new float3 ( 1 , 1 , 1 ) } ,
new RarityTier { Name = "Rare" , Quality = . 5f , Rarity = 1 , Color = new float3 ( 1 , 1 , 1 ) }
} ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , settings , _ = > { } ) ;
var lamp = cache . GetByName < GearData > ( "Lamp" ) ;
2026-09-17 05:05:09 +02:00
var commonLot = items . Lots . Add ( new Lot
2026-09-17 04:17:13 +02:00
{
Design = cache . RefOf < ItemData > ( lamp ) ,
Origin = new Attributed ( ) ,
Quality = . 2f ,
Roles = new List < RoleFill > { new RoleFill { Role = "lens" , Quality = . 9f } }
} ) ;
2026-09-17 05:05:09 +02:00
var rareLot = items . Lots . Add ( new Lot
{
Design = cache . RefOf < ItemData > ( lamp ) ,
Origin = new Attributed ( ) ,
Quality = . 6f ,
Roles = new List < RoleFill > ( )
} ) ;
var instance = ( EquippableItem ) items . CreateInstance ( commonLot ) ;
var rareInstance = ( EquippableItem ) items . CreateInstance ( rareLot ) ;
2026-09-17 04:17:13 +02:00
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
var withRole = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . Quality , Exponent = 1 , Role = "lens" } } } ;
var noRole = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . Quality , Exponent = 1 } } } ;
2026-09-17 04:17:13 +02:00
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 ) ;
2026-09-17 05:05:09 +02:00
Assert . Equal ( . 5f , items . GetTier ( rareInstance ) . tier . Quality , 3 ) ;
// Minting a second instance from an existing lot must not re-roll the lot's quality.
var secondInstance = ( EquippableItem ) items . CreateInstance ( commonLot ) ;
Assert . Equal ( . 2f , items . Lots [ commonLot ] . Quality , 3 ) ;
Assert . Equal ( . 2f , items . GetTier ( secondInstance ) . tier . Quality , 3 ) ;
2026-09-17 04:17:13 +02:00
}
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants
StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response
fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a
plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and
the Performance() comment that already claimed this was enforced. The writable-path
hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs),
the one function every tool, test and migration script writes catalog documents
through, validates before a write reaches disk instead of waiting for the next Open().
CultCache Studio's generic editor bypasses Upsert and still has no per-document
validation hook -- that hole is CultLib's, not this repo's, and stays named in the code
rather than silently assumed closed.
EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the
same shape HeatResponseTests already hand-authors) because dozens of existing test
fixtures across the suite build designs that never cared about heat and defaulted into
the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal
without this would have required touching every one of those fixtures instead of the
root cause.
Four new mutations pin S7's survivors, each with its own dedicated test rather than a
coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f
outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring
its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the
exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own
runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes
it unobservable at every input, verified by an exhaustive numeric sweep -- and is
recorded as such rather than forced into a fake kill.
80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
// S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own
// declared Exponent, not always to 1. StatsReadTheLot uses Exponent = 1 throughout, which cannot distinguish
// "read the term's exponent" from "always use 1" -- pow(x, 1) == x either way. Exponent = 2 here can: a
// mutation that drops the term's own exponent (using 1f regardless of what is declared) reads .5 instead of
// the correct .25.
[Fact]
public void QualityTermAppliesItsOwnExponent ( )
{
using var cache = Open ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var lamp = cache . GetByName < GearData > ( "Lamp" ) ;
var lotId = items . Lots . Add ( new Lot { Design = cache . RefOf < ItemData > ( lamp ) , Origin = new Attributed ( ) , Quality = . 5f , Roles = new List < RoleFill > ( ) } ) ;
var instance = ( EquippableItem ) items . CreateInstance ( lotId ) ;
var squared = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . Quality , Exponent = 2 } } } ;
Assert . Equal ( . 25f , items . Evaluate ( squared , instance ) , 3 ) ;
}
2026-09-17 05:11:43 +02:00
// F4: GetPrice reads the lot's quality through GameplaySettings.QualityPriceModifier.
[Fact]
public void GetPriceReadsLotQuality ( )
{
using var cache = Open ( ) ;
var settings = RunSaveTests . TestSettings ( ) ;
settings . QualityPriceModifier = new ExponentialLerp { Minimum = 0 , Maximum = 1 , Exponent = 1 } ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , settings , _ = > { } ) ;
var lamp = cache . GetByName < GearData > ( "Lamp" ) ;
var lowLot = items . Lots . Add ( new Lot { Design = cache . RefOf < ItemData > ( lamp ) , Origin = new Attributed ( ) , Quality = . 2f , Roles = new List < RoleFill > ( ) } ) ;
var highLot = items . Lots . Add ( new Lot { Design = cache . RefOf < ItemData > ( lamp ) , Origin = new Attributed ( ) , Quality = . 8f , Roles = new List < RoleFill > ( ) } ) ;
var low = ( EquippableItem ) items . CreateInstance ( lowLot ) ;
var high = ( EquippableItem ) items . CreateInstance ( highLot ) ;
Assert . True ( items . GetPrice ( high ) > items . GetPrice ( low ) ) ;
}
// F4: the durability exponent path in ItemManager.Evaluate reads the lot's quality (GameplaySettings.
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// DurabilityQuality{Min,Max,Exponent}), independently of the quality-for-role term (absent here -- the
// stat declares only a Durability term).
2026-09-17 05:11:43 +02:00
[Fact]
public void EvaluateDurabilityExponentReadsLotQuality ( )
{
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var durable = cache . Upsert ( new GearData { Name = "Durable" , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Price = 10 , Durability = 100 } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var lowLot = items . Lots . Add ( new Lot { Design = new CultRecordRef < ItemData > ( durable . Key ) , Origin = new Attributed ( ) , Quality = . 1f , Roles = new List < RoleFill > ( ) } ) ;
var highLot = items . Lots . Add ( new Lot { Design = new CultRecordRef < ItemData > ( durable . Key ) , Origin = new Attributed ( ) , Quality = . 9f , Roles = new List < RoleFill > ( ) } ) ;
var low = ( EquippableItem ) items . CreateInstance ( lowLot ) ;
var high = ( EquippableItem ) items . CreateInstance ( highLot ) ;
low . Durability = items . GetData ( low ) . Durability / 2 ;
high . Durability = items . GetData ( high ) . Durability / 2 ;
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
var stat = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . Durability , Exponent = 1 } } } ;
2026-09-17 05:11:43 +02:00
Assert . NotEqual ( items . Evaluate ( stat , low ) , items . Evaluate ( stat , high ) ) ;
}
// F4: EquippedItem's thermal exponent (Entity.cs's EquippedItem constructor) reads the lot's quality
// (GameplaySettings.ThermalQuality{Min,Max,Exponent}), through a real equip on a live entity.
[Fact]
public void EquippedItemThermalExponentReadsLotQuality ( )
{
using var cache = Open ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var hullData = cache . GetByName < HullData > ( "Skiff" ) ;
var lampData = cache . GetByName < GearData > ( "Lamp" ) ;
// TestSettings has one tier, so materializing through a Loadout always rolls the same quality; mint the
// gear's lot directly at a chosen quality and equip it onto a bare ship instead.
Ship BuildWithGearQuality ( float quality )
{
var hullItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( hullData , maker , . 5f ) ) ;
var ship = new Ship ( items , null , hullItem , new EntitySettings ( ) ) ;
var gearItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( lampData , maker , quality ) ) ;
Assert . True ( ship . TryEquip ( gearItem , HardpointCell ) ) ;
return ship ;
}
var lowEquipped = BuildWithGearQuality ( . 1f ) . Equipment . Single ( e = > e . Data is GearData ) ;
var highEquipped = BuildWithGearQuality ( . 9f ) . Equipment . Single ( e = > e . Data is GearData ) ;
Assert . NotEqual ( lowEquipped . ThermalExponent , highEquipped . ThermalExponent ) ;
}
2026-09-18 16:19:33 +02:00
// Cut 0 (docs/stats-and-power-cut.md): the collapsed evaluation path (PerformanceStat.Evaluate(IStatContext))
// gives the unequipped and the equipped-at-full-health-and-optimal-temperature cases the same number, for a
// design whose heat curve genuinely reaches full performance at its plateau. Before the cut this equality was
// not even expressible: the two paths were separate hand-written formulas (ItemManager.Evaluate and
// EquippedItem.Evaluate) with no shared entry point to assert against. It is expressible now because both
// resolve through the one function via an IStatContext, which is the structural point of the cut.
//
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored shape is minimum, maximum, optimum and plateau
// width, with linear falloffs -- no curve. This fixture authors a plateau (40-60 out of a 0-100 range) that
// reaches exactly 1.0 across it, the R-heat equivalent of the old flat-topped bezier fixture, so the
// equality this test pins (unequipped agrees with equipped-at-optimum) is expressible the same way Cut 0
// left it.
2026-09-18 16:19:33 +02:00
[Fact]
public void UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature ( )
{
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var hullData = cache . GetByName < HullData > ( "Skiff" ) ;
var thermalGear = cache . Upsert ( new GearData
{
Name = "Thermal" , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Price = 10 ,
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
Durability = 100 , MinimumTemperature = 0 , MaximumTemperature = 100 ,
OptimalTemperature = 50 , PlateauWidth = 20
2026-09-18 16:19:33 +02:00
} ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var thermalData = cache . Get ( thermalGear ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var hullItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( hullData , maker , . 5f ) ) ;
var ship = new Ship ( items , null , hullItem , new EntitySettings ( ) ) ;
var gearItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( thermalData , maker , . 5f ) ) ;
Assert . True ( ship . TryEquip ( gearItem , HardpointCell ) ) ;
var equipped = ship . Equipment . Single ( e = > e . Data is GearData ) ;
// Drive the entity's temperature to the design's optimal, at full durability (CreateInstance's default).
foreach ( var cell in equipped . InsetShape . Coordinates )
ship . Temperature [ cell . x , cell . y ] = thermalData . OptimalTemperature ;
equipped . UpdatePerformance ( ) ;
Assert . Equal ( 1f , equipped . ThermalPerformance , 4 ) ;
Assert . Equal ( 1f , equipped . DurabilityPerformance , 4 ) ;
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
var stat = new PerformanceStat
{
Min = 0 , Max = 1 ,
Terms = { new StatTerm { Source = StatSource . Heat , Exponent = 1 } , new StatTerm { Source = StatSource . Durability , Exponent = 1 } }
} ;
2026-09-18 16:19:33 +02:00
var equippedResult = equipped . Evaluate ( stat ) ;
var unequippedResult = items . Evaluate ( stat , gearItem ) ;
Assert . Equal ( unequippedResult , equippedResult , 5 ) ;
}
// Cut 0: the unequipped context has no heat, by name (UnequippedStatContext.HeatFactor is a fixed 1),
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// regardless of the item's actual temperature or the term's exponent. This is one of the two named
// disagreements the cut preserves rather than resolves -- the trade menu shows the un-discounted value on
// purpose. Mutation: have HeatFactor read a real thermal performance instead of the constant 1; this goes
2026-09-18 16:19:33 +02:00
// red because the item is equipped far off its optimal temperature while unequipped stays unchanged.
[Fact]
public void UnequippedIgnoresHeatRegardlessOfTemperature ( )
{
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var hullData = cache . GetByName < HullData > ( "Skiff" ) ;
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// A peak with zero plateau width: performance reaches 1.0 only exactly at the optimum, matching the old
// curve fixture's single-point peak at .5 of its range.
2026-09-18 16:19:33 +02:00
var thermalGear = cache . Upsert ( new GearData
{
Name = "ColdThermal" , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Price = 10 ,
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
Durability = 100 , MinimumTemperature = 0 , MaximumTemperature = 100 ,
OptimalTemperature = 50 , PlateauWidth = 0
2026-09-18 16:19:33 +02:00
} ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var thermalData = cache . Get ( thermalGear ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var hullItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( hullData , maker , . 5f ) ) ;
var ship = new Ship ( items , null , hullItem , new EntitySettings ( ) ) ;
var gearItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( thermalData , maker , . 5f ) ) ;
Assert . True ( ship . TryEquip ( gearItem , HardpointCell ) ) ;
var equipped = ship . Equipment . Single ( e = > e . Data is GearData ) ;
// Drive the item to the coldest end of its range -- far from optimal, ThermalPerformance -> 0.
foreach ( var cell in equipped . InsetShape . Coordinates )
ship . Temperature [ cell . x , cell . y ] = thermalData . MinimumTemperature ;
equipped . UpdatePerformance ( ) ;
Assert . True ( equipped . ThermalPerformance < 0.01f ) ;
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
var stat = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . Heat , Exponent = 1 } } } ;
2026-09-18 16:19:33 +02:00
var equippedResult = equipped . Evaluate ( stat ) ;
var unequippedResult = items . Evaluate ( stat , gearItem ) ;
Assert . True ( equippedResult < 0.01f ) ; // heat tanks the equipped read
Assert . Equal ( 1f , unequippedResult , 3 ) ; // unequipped never reads heat at all
}
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// Cut 1: ConsumableItemEffect's progress-through-duration is now its own declared term (ConsumableProgress)
// rather than a hard override of "heat" applied to every stat regardless of what it declares. A stat that
// declares the term reads progress; one that does not is unaffected by it, the same as any other source a
// context has no reader for. Mutation: apply ConsumableProgressFactor unconditionally instead of gating it
// on a declared term; the "undeclared" assertion goes red because Undeclared would then also read .5.
2026-09-18 16:19:33 +02:00
[Fact]
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
public void ConsumableProgressAppliesOnlyWhenDeclared ( )
2026-09-18 16:19:33 +02:00
{
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var hullData = cache . GetByName < HullData > ( "Skiff" ) ;
var ramp = new BezierCurve { Keys = new [ ] { new float4 ( 0f , 0f , 0f , 0f ) , new float4 ( 1f , 1f , 0f , 0f ) } } ;
var consumableRef = cache . Upsert ( new ConsumableItemData { Name = "Booster" , Duration = 10 , Effectiveness = ramp } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var consumableData = cache . Get ( consumableRef ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var hullItem = ( EquippableItem ) items . CreateInstance ( items . CreateLot ( hullData , maker , . 5f ) ) ;
var ship = new Ship ( items , null , hullItem , new EntitySettings ( ) ) ;
2026-10-09 20:19:22 +02:00
var consumableItem = ( ConsumableItem ) items . CreateInstance ( items . CreateLot ( consumableData , maker , . 5f ) ) ;
2026-09-18 16:19:33 +02:00
var effect = new ConsumableItemEffect ( consumableItem , ship ) ;
effect . Update ( 5f ) ; // half the duration elapsed -> progress .5 -> effectiveness .5 on a linear ramp
Cut 1: terms replace the four fixed exponents; R-heat
PerformanceStat's four fixed fields (HeatExponentMultiplier,
DurabilityExponentMultiplier, QualityExponent, FromRole) tombstone into a
declared List<StatTerm> (source + exponent + role), walked by the one
evaluation path Cut 0 unified. A stat with no terms resolves to Max (the
same identity a stat with all-zero exponents already produced), so the
migration is exact: a zero multiplier becomes no term, not a term with
exponent 0.
R-heat (operator, docs/stats-and-power-cut.md): EquippableItemData's
HeatPerformanceCurve (a BezierCurve, plus the 100-sample scan that inferred
OptimalTemperature and its cache) is retired in favor of an authored
minimum, maximum, optimum and plateau width, with linear falloff outside
the plateau. StatValidation refuses an optimum outside its bounds or a
negative plateau width, loudly, at catalog load. The plateau's consequence
rides along for free: Wear's thermal term is pow(ThermalPerformance, ...),
and Performance() now returns exactly 1 across the plateau, so a
well-managed item takes wear only from deltaTemp there -- the
operational-lifespan lever the operator asked for.
ConsumableItemEffect's progress-through-duration substitution for "heat"
becomes its own StatSource (ConsumableProgress) rather than an unconditional
override applied regardless of what a stat declares.
Tests: LoadoutTests' four PerformanceStat-shaped fixtures rebuilt against
Terms/authored heat fields; HeatResponseTests.cs (new) pins load
validation, the plateau shape, and the three wear-lever claims (held
inside takes no thermal wear, held outside does, a fast swing across still
wears) each under its own mutation in tests/mutation_tests_stats_power_cut1.py
(new, 10 rules including a no-op control; all pass).
64 pre-Cut-1 tests still pass unmodified; 71 total with the 7 new ones.
Unity batchmode: 0 error CS. ShieldPanelCut1Verify and Cut2Verify pass;
Cut3Verify fails with an unrelated pre-existing "Kernel 'KInit' not found"
compute-shader error, reproduced against Cut 0's own landing state with no
shader/compute file in this diff -- flagged for Soul, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:18:06 +02:00
// Exponent 2, not 1: pow(.5, 2) == .25 differs from the raw progress .5, so a mutation that dropped the
// term's own exponent (using the raw effectiveness value unexponentiated) is distinguishable from this.
var declared = new PerformanceStat { Min = 0 , Max = 1 , Terms = { new StatTerm { Source = StatSource . ConsumableProgress , Exponent = 2 } } } ;
var undeclared = new PerformanceStat { Min = 0 , Max = 1 } ;
Assert . Equal ( . 25f , effect . Evaluate ( declared ) , 3 ) ;
Assert . Equal ( 1f , effect . Evaluate ( undeclared ) , 3 ) ;
2026-09-18 16:19:33 +02:00
}
2026-09-17 05:11:43 +02:00
// F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread
// (FactionProductData.Roles), not the design-wide default.
[Fact]
public void CreateLotFillsRolesFromProductSpread ( )
{
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var lensGear = cache . Upsert ( new GearData
{
Name = "Lensed" , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Price = 10 ,
Roles = { new ItemRole { Name = "lens" } }
} ) ;
cache . Upsert ( new FactionProductData
{
Name = "Lensed by Maker" , Design = new CultRecordRef < CraftedItemData > ( lensGear . Key ) , Manufacturer = maker ,
Roles = { new ProductRole { Role = "lens" , Mean = . 9f , StandardDeviation = 0 } }
} ) ;
cache . FlushAsync ( ) . Wait ( ) ;
var product = cache . GetAll < FactionProductData > ( ) . Single ( p = > p . Name = = "Lensed by Maker" ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var lotId = items . CreateLot ( product ) ;
var roleFill = items . Lots [ lotId ] . Roles . Single ( r = > r . Role = = "lens" ) ;
Assert . Equal ( . 9f , roleFill . Quality , 3 ) ;
}
// F4: Brand reads the maker faction off a Produced origin, not only Attributed.
[Fact]
public void BrandReadsProducedFaction ( )
{
using var cache = Open ( ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var lamp = cache . GetByName < GearData > ( "Lamp" ) ;
var lotId = items . Lots . Add ( new Lot
{
Design = cache . RefOf < ItemData > ( lamp ) ,
Origin = new Produced { Faction = maker , Station = 1 , Facility = 2 , Inputs = Array . Empty < int > ( ) } ,
Quality = . 5f , Roles = new List < RoleFill > ( )
} ) ;
var instance = ( EquippableItem ) items . CreateInstance ( lotId ) ;
var ( brandMaker , _ ) = items . Brand ( instance ) ;
Assert . NotNull ( brandMaker ) ;
Assert . Equal ( maker . Key , cache . RefOf ( brandMaker ) . Key ) ;
}
2026-09-14 22:28:24 +02:00
[Fact]
public void OutOfRangeWeaponGroupIndexBuildsNothing ( )
{
using var cache = Open ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
var stale = Armed ( cache ) ;
2026-09-14 22:28:24 +02:00
stale . WeaponGroups = new [ ] { new [ ] { 0 , 2 } , new [ ] { - 1 } } ;
var failures = new List < string > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Assert . Null ( Build ( items , stale , failures ) ) ;
2026-09-14 22:28:24 +02:00
Assert . Equal ( new [ ] { "weapon group: no slot 2" , "weapon group: no slot -1" } , failures ) ;
}
2026-09-14 22:38:16 +02:00
[Fact]
public void UnequippingAGroupedWeaponRemovesItFromItsGroups ( )
{
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
using var cache = Open ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
var ship = Build ( items , Armed ( cache ) , new List < string > ( ) ) ;
2026-09-14 22:38:16 +02:00
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 ) ) ;
}
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
// 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 ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
var lampGrouped = WithDesign ( Armed ( cache ) , 0 , cache , "Lamp" ) ;
var failures = new List < string > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Assert . Null ( Build ( items , lampGrouped , failures ) ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
Assert . Equal ( "weapon group: slot 0,0: Lamp is not a weapon" , Assert . Single ( failures ) ) ;
}
[Fact]
public void MoreWeaponGroupsThanTheGameHasBuildsNothing ( )
{
using var cache = Open ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
var wide = Armed ( cache ) ;
wide . WeaponGroups = wide . WeaponGroups . Append ( new [ ] { 0 } ) . ToArray ( ) ;
var failures = new List < string > ( ) ;
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Assert . Null ( Build ( items , wide , failures ) ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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 ( ) ;
2026-09-17 04:17:13 +02:00
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
Refuse non-weapon groups, fix the group count, and widen loadout price (Cut 10 fix)
Materialize now reports a weapon group index whose slot design has no
WeaponData, and a loadout with more groups than WeaponGroupCount. The ship
always gets exactly WeaponGroupCount groups, and a shorter loadout is padded
with empty groups. Both checks run before anything is built.
Price is summed as long, so the hull add can no longer wrap to a negative
price that pays the player. A price beyond int credits is simply unaffordable.
Deleted Capture's index >= 0 filter and its test. Every production writer of
WeaponGroups items (WeaponGroupAssignment, GenerateWeaponGroups, Unpack replay
of Pack, Materialize) groups only weapons, and TryUnequip removes weapons from
groups, so nothing produces an ungrouped index any more.
The Materialize comment now says a failed fit may already have advanced
itemManager.Random.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:51:37 +02:00
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 ( ) ;
}
}
2026-09-17 18:22:22 +02:00
// Cut 0 (settings-globals-cut.md): Entity's ctor must copy the template it is given. No entity may alias
// GameplaySettings.DefaultEntitySettings, or a write through one ship (InventoryMenu's Shutdown Threshold
// edit) would silently move the default for every other ship, and once settings are a catalog global, the
// cached record itself.
[Fact]
public void EntitiesDoNotShareDefaultEntitySettings ( )
{
using var cache = Open ( ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , RunSaveTests . TestSettings ( ) , _ = > { } ) ;
var maker = cache . RefOf ( cache . GetByName < Faction > ( "Maker" ) ) ;
var hullData = cache . GetByName < HullData > ( "Skiff" ) ;
var template = items . GameplaySettings . DefaultEntitySettings ;
var originalThreshold = template . ShutdownPerformance ;
Ship BuildShip ( ) = > new Ship ( items , null , ( EquippableItem ) items . CreateInstance ( items . CreateLot ( hullData , maker , . 5f ) ) , template ) ;
var shipA = BuildShip ( ) ;
var shipB = BuildShip ( ) ;
shipA . Settings . ShutdownPerformance = originalThreshold + 1 ;
Assert . NotSame ( template , shipA . Settings ) ;
Assert . NotSame ( shipA . Settings , shipB . Settings ) ;
Assert . Equal ( originalThreshold , shipB . Settings . ShutdownPerformance ) ;
Assert . Equal ( originalThreshold , template . ShutdownPerformance ) ;
}
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
// Everything available: for tests about placement and failure lists.
2026-09-30 12:33:00 +02:00
private static Entity Build ( ItemManager items , Loadout loadout , List < string > failures ) = >
Make loadouts authored catalog presets with an editor capture command
Loadouts are ship presets (variants) for spawners, not a player feature.
- Route Loadout to the catalog; the player store no longer holds it.
- Materialize takes no credits: affordability, the charge and Loadouts.Price
are deleted, since only the player menu priced loadouts. Availability,
key-order product choice, all-or-nothing failures and the weapon-group
rules stay.
- Delete the InventoryPanel Save Loadout and Restore Loadout entries and
RestoreLoadout, which also removes its docking-bay overwrite.
- The editor opens the catalog writable in the one process cache, and the
editor-only console command capturepreset "<name>" [replace] captures the
piloted ship. Loadouts.Commit keys a preset by name, refuses a same-name
replace unless asked, and commits conditionally on that key. The single-file
store then lands only that record onto the file as it is on disk. An
unconditional commit would re-serialize every live catalog instance,
including any that play has mutated.
- Tests: capture -> commit -> fresh reopen round-trip, explicit replace with
no duplicate, and a preset commit that does not persist a mutated live
Faction. The money-only tests are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:09:43 +02:00
Loadouts . Materialize ( items , null , loadout , _ = > true , failures ) ;
2026-09-14 22:27:03 +02:00
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 ( ) ;
}
}