Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +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 ;
Fix F1: PersistedBehaviors as a pairs array, per Cut 0's ruling
Dictionary<int2, PersistentBehaviorData[]> has no hash-resistant comparer
under CultCache's UntrustedData security, so any run store holding an
entity threw MessagePackSerializationException on reopen, even when the
dictionary was empty. EntityPack.PersistedBehaviors (key 5, unchanged)
becomes (int2 position, PersistentBehaviorData[] data)[], matching
Equipment/CargoBays/DockingBays. Pack writes pairs; Unpack builds a local
lookup before restoring behaviors.
PersistentBehaviorData had no concrete leaf and no Union, so nothing could
ever exercise its wire format; added MarkerPersistentBehaviorData as the
one placeholder leaf so ReopenedPackKeepsPersistedBehaviors can round-trip
real data ahead of a real IPersistentBehavior implementer landing.
Tests: RunSaveTests.BarePack uses an empty pairs array.
Added ReopenedPackKeepsPersistedBehaviors (a hand-built pack with two
positions, each with one persisted behavior, round-trips through commit
and reopen) and ReopenSurvivesAGeneratedShipWithEntities (a real ship from
LoadoutGenerator against a catalog fixture, committed into a SavedZone and
reopened, pinning the defect at the layer Soul's probe found it).
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots go
green; 36 of 36 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:58:15 +02:00
using Random = CultMath . Random ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
// The run lifecycle over all three stores: a save is one commit with stable zone keys, and a clear removes every run record.
public sealed class RunSaveTests : IDisposable
{
private readonly string _root = Path . Combine ( Path . GetTempPath ( ) , "aetheria-runsave-" + 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" ) ;
public RunSaveTests ( )
{
Directory . CreateDirectory ( _root ) ;
using var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) ;
cache . Commit ( batch = >
{
batch . Upsert ( new TestCatalogGlobal { Name = "Temperament" } ) ;
batch . Upsert ( new Faction { Name = "Adrasteia" , ShortName = "ADR" } ) ;
} ) ;
}
public void Dispose ( ) = > Directory . Delete ( _root , true ) ;
private CultCache Open ( ) = > AetheriaStores . Open ( Catalog , Run , Player ) ;
[Fact]
public void RepeatedSavesKeepRecordCountConstant ( )
{
ZonePack contents = null ;
for ( var i = 0 ; i < 5 ; i + + )
{
using ( var cache = Open ( ) )
{
contents ? ? = StageZoneContents ( cache ) ;
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , Game ( cache ) , Zones ( 3 , contents , 0 ) , new ProvenanceLedger ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
}
using ( var cache = Open ( ) )
{
2026-09-17 04:17:13 +02:00
Assert . Equal ( 8 , RunRecordCount ( cache ) ) ;
Assert . Equal ( 8 , RecordsIn ( Run ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
Assert . Equal ( new [ ] { "savedzone-0" , "savedzone-1" , "savedzone-2" } , SavedZoneKeys ( cache ) ) ;
}
}
}
[Fact]
public void SaveRemovesZonesNoLongerInTheRun ( )
{
using ( var cache = Open ( ) )
{
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , Game ( cache ) , Zones ( 3 , null , - 1 ) , new ProvenanceLedger ( ) ) ;
RunSave . Commit ( cache , Game ( cache ) , Zones ( 2 , null , - 1 ) , new ProvenanceLedger ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
}
using ( var cache = Open ( ) )
Assert . Equal ( new [ ] { "savedzone-0" , "savedzone-1" } , SavedZoneKeys ( cache ) ) ;
}
[Fact]
public void SaveWritesOnlyTheRunStore ( )
{
using ( var cache = Open ( ) )
cache . Commit ( batch = > batch . Upsert ( new PlayerSettings ( ) ) ) ;
var before = new [ ] { Catalog , Player } . Select ( Hash ) . ToArray ( ) ;
using ( var cache = Open ( ) )
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , Game ( cache ) , Zones ( 3 , StageZoneContents ( cache ) , 0 ) , new ProvenanceLedger ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
Assert . Equal ( before , new [ ] { Catalog , Player } . Select ( Hash ) . ToArray ( ) ) ;
2026-09-17 04:17:13 +02:00
Assert . Equal ( 8 , RecordsIn ( Run ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
}
[Fact]
public void ResumeThenSaveKeepsUnloadedZoneContents ( )
{
using ( var cache = Open ( ) )
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , Game ( cache ) , Zones ( 2 , StageZoneContents ( cache ) , 1 ) , new ProvenanceLedger ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
using ( var cache = Open ( ) )
{
var galaxy = new Galaxy ( cache , cache . GetGlobal < SavedGame > ( ) , _ = > { } ) ;
2026-09-17 04:17:13 +02:00
var itemManager = new ItemManager ( cache , new ProvenanceLedger ( ) , TestSettings ( ) , _ = > { } ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
galaxy . Zones [ 0 ] . Contents = new Zone ( itemManager , new PlanetSettings ( ) , new ZonePack ( ) , galaxy . Zones [ 0 ] , galaxy ) ;
var ( game , zones ) = RunSave . Capture ( cache , galaxy , galaxy . Zones [ 0 ] . Contents , null , false , new SavedActionBarBinding [ 0 ] ) ;
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , game , zones , RunSave . Lots ( cache ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
}
using ( var cache = Open ( ) )
{
var unloaded = cache . Get ( cache . GetGlobal < SavedGame > ( ) . Zones [ 1 ] ) ;
Assert . NotNull ( unloaded . Contents ) ;
Assert . NotNull ( cache . Get ( unloaded . Contents . Orbits [ 1 ] ) ) ;
Assert . NotNull ( cache . Get ( unloaded . Contents . Planets [ 0 ] ) ) ;
}
}
[Fact]
public void ClearRemovesEveryRunRecord ( )
{
using ( var cache = Open ( ) )
{
cache . Commit ( batch = > batch . Upsert ( new PlayerSettings ( ) ) ) ;
2026-09-17 04:17:13 +02:00
RunSave . Commit ( cache , Game ( cache ) , Zones ( 3 , StageZoneContents ( cache ) , 0 ) , new ProvenanceLedger ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
}
var before = new [ ] { Catalog , Player } . Select ( Hash ) . ToArray ( ) ;
using ( var cache = Open ( ) )
RunSave . Clear ( cache ) ;
using ( var cache = Open ( ) )
{
Assert . Equal ( 0 , RunRecordCount ( cache ) ) ;
Assert . Null ( cache . GetGlobal < SavedGame > ( ) ) ;
}
Assert . Equal ( 0 , RecordsIn ( Run ) ) ;
Assert . Equal ( before , new [ ] { Catalog , Player } . Select ( Hash ) . ToArray ( ) ) ;
}
2026-09-17 04:17:13 +02:00
// Lot 1 sits on the root ship's hull; lot 3 (Produced from facility 4, input 5) sits in a docked child's
// docking-bay contents; lots 2 and 6 are minted but referenced by nothing packed. Only the reachable closure
// {1, 3, 4, 5} survives into the written copy; the live ledger keeps every lot.
[Fact]
public void CommitKeepsOnlyReachableLots ( )
{
using ( var cache = Open ( ) )
{
var lots = new ProvenanceLedger { NextLot = 7 } ;
lots . Lots [ 1 ] = new Lot { Origin = new Attributed ( ) } ;
lots . Lots [ 2 ] = new Lot { Origin = new Attributed ( ) } ;
lots . Lots [ 3 ] = new Lot { Origin = new Produced { Facility = 4 , Inputs = new [ ] { 5 } } } ;
lots . Lots [ 4 ] = new Lot { Origin = new Attributed ( ) } ;
lots . Lots [ 5 ] = new Lot { Origin = new Extracted ( ) } ;
lots . Lots [ 6 ] = new Lot { Origin = new Attributed ( ) } ;
var child = BarePack ( hull : null , dockingBayContents : new ( int2 , ItemInstance ) [ ] [ ]
{
new ( int2 , ItemInstance ) [ ] { ( default , new CompoundCommodity { Lot = 3 } ) }
} ) ;
var root = BarePack ( hull : new EquippableItem { Lot = 1 } , children : new EntityPack [ ] { child } ) ;
var zones = new [ ]
{
new SavedZone
{
Name = "Zone 0" , AdjacentZones = Array . Empty < int > ( ) , Factions = Array . Empty < int > ( ) , Owner = - 1 ,
Contents = new ZonePack { Entities = new List < EntityPack > { root } }
}
} ;
RunSave . Commit ( cache , Game ( cache ) , zones , lots ) ;
// The live ledger is never pruned.
Assert . Equal ( new [ ] { 1 , 2 , 3 , 4 , 5 , 6 } , lots . Lots . Keys . OrderBy ( k = > k ) ) ;
}
using ( var cache = Open ( ) )
{
var stored = RunSave . Lots ( cache ) ;
Assert . Equal ( new [ ] { 1 , 3 , 4 , 5 } , stored . Lots . Keys . OrderBy ( k = > k ) ) ;
Assert . Equal ( 7 , stored . NextLot ) ;
}
}
2026-09-17 05:11:43 +02:00
// F4: RunSave.Commit must take GC roots from every committed zone, not only the first. Two zones, each with a
// different lot on its hull; both must survive.
[Fact]
public void CommitTakesRootsFromEveryZone ( )
{
using ( var cache = Open ( ) )
{
var lots = new ProvenanceLedger { NextLot = 3 } ;
lots . Lots [ 1 ] = new Lot { Origin = new Attributed ( ) } ;
lots . Lots [ 2 ] = new Lot { Origin = new Attributed ( ) } ;
var zones = new [ ]
{
new SavedZone
{
Name = "Zone 0" , AdjacentZones = Array . Empty < int > ( ) , Factions = Array . Empty < int > ( ) , Owner = - 1 ,
Contents = new ZonePack { Entities = new List < EntityPack > { BarePack ( hull : new EquippableItem { Lot = 1 } ) } }
} ,
new SavedZone
{
Name = "Zone 1" , AdjacentZones = Array . Empty < int > ( ) , Factions = Array . Empty < int > ( ) , Owner = - 1 ,
Contents = new ZonePack { Entities = new List < EntityPack > { BarePack ( hull : new EquippableItem { Lot = 2 } ) } }
}
} ;
RunSave . Commit ( cache , Game ( cache ) , zones , lots ) ;
}
using ( var cache = Open ( ) )
{
var stored = RunSave . Lots ( cache ) ;
Assert . Equal ( new [ ] { 1 , 2 } , stored . Lots . Keys . OrderBy ( k = > k ) ) ;
}
}
2026-09-17 04:17:13 +02:00
[Fact]
public void MissingLotIsLoud ( )
{
var ledger = new ProvenanceLedger ( ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > ledger [ 0 ] ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > ledger . Reachable ( new [ ] { 9 } ) ) ;
}
Fix F1: PersistedBehaviors as a pairs array, per Cut 0's ruling
Dictionary<int2, PersistentBehaviorData[]> has no hash-resistant comparer
under CultCache's UntrustedData security, so any run store holding an
entity threw MessagePackSerializationException on reopen, even when the
dictionary was empty. EntityPack.PersistedBehaviors (key 5, unchanged)
becomes (int2 position, PersistentBehaviorData[] data)[], matching
Equipment/CargoBays/DockingBays. Pack writes pairs; Unpack builds a local
lookup before restoring behaviors.
PersistentBehaviorData had no concrete leaf and no Union, so nothing could
ever exercise its wire format; added MarkerPersistentBehaviorData as the
one placeholder leaf so ReopenedPackKeepsPersistedBehaviors can round-trip
real data ahead of a real IPersistentBehavior implementer landing.
Tests: RunSaveTests.BarePack uses an empty pairs array.
Added ReopenedPackKeepsPersistedBehaviors (a hand-built pack with two
positions, each with one persisted behavior, round-trips through commit
and reopen) and ReopenSurvivesAGeneratedShipWithEntities (a real ship from
LoadoutGenerator against a catalog fixture, committed into a SavedZone and
reopened, pinning the defect at the layer Soul's probe found it).
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots go
green; 36 of 36 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:58:15 +02:00
// F1: the reopen defect was found through a hand-built pack; this pins the same defect class at the layer it
// actually failed, a real ship generated by LoadoutGenerator against a catalog fixture, committed into a
// SavedZone and reopened (Soul's probe in docs/headless-playground-cut.md §2.6).
[Fact]
public void ReopenSurvivesAGeneratedShipWithEntities ( )
{
using ( var cache = AetheriaStores . Open ( Catalog , catalogWritable : true ) )
{
var maker = cache . Upsert ( new Faction { Name = "Generator Maker" , ShortName = "GEN" } ) ;
var hullShape = new Shape ( 4 , 4 ) ;
foreach ( var cell in hullShape . AllCoordinates ) hullShape [ cell ] = true ;
var hull = cache . Upsert ( new HullData
{
Name = "Runabout" , HullType = HullType . Ship , Shape = hullShape , Price = 50 ,
Hardpoints = { new HardpointData { Type = HardpointType . Sensors , Position = new int2 ( 0 , 0 ) , Shape = new Shape ( ) } }
} ) ;
var gear = cache . Upsert ( new GearData { Name = "Scanner" , Hardpoint = HardpointType . Sensors , Shape = new Shape ( ) , Price = 5 } ) ;
var cargo = cache . Upsert ( new CargoBayData { Name = "Hold" , Shape = new Shape ( ) , InteriorShape = new Shape ( ) , Price = 3 } ) ;
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 [ ] { ( "Runabout by Generator Maker" , hull . Key ) , ( "Scanner by Generator Maker" , gear . Key ) , ( "Hold by Generator Maker" , cargo . Key ) , ( "Cap by Generator Maker" , capacitor . Key ) } )
cache . Upsert ( new FactionProductData { Name = name , Design = new CultRecordRef < CraftedItemData > ( design ) , Manufacturer = maker } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
EntityPack pack ;
ProvenanceLedger lots ;
using ( var cache = Open ( ) )
{
var random = new Random ( 7 ) ;
var items = new ItemManager ( cache , new ProvenanceLedger ( ) , TestSettings ( ) , _ = > { } ) ;
var generator = new LoadoutGenerator ( ref random , items , null , null , null , . 5f ) ;
pack = generator . GenerateShipLoadout ( candidate = > candidate . Name = = "Runabout" ) ;
Assert . NotNull ( pack ) ;
lots = items . Lots ;
var game = new SavedGame
{
Factions = Array . Empty < CultRecordRef < Faction > > ( ) ,
Relationships = Array . Empty < FactionRelationship > ( ) ,
HomeZones = new Dictionary < int , int > ( ) ,
BossZones = new Dictionary < int , int > ( ) ,
DiscoveredZones = new [ ] { 0 } ,
ActionBarBindings = new SavedActionBarBinding [ 0 ] ,
Exit = - 1
} ;
var zones = new [ ]
{
new SavedZone
{
Name = "Zone 0" , AdjacentZones = Array . Empty < int > ( ) , Factions = Array . Empty < int > ( ) , Owner = - 1 ,
Contents = new ZonePack { Entities = new List < EntityPack > { pack } }
}
} ;
RunSave . Commit ( cache , game , zones , lots ) ;
}
using ( var cache = Open ( ) )
{
var storedLots = RunSave . Lots ( cache ) ;
var run = cache . GetGlobal < SavedGame > ( ) ;
var zone = cache . Get ( run . Zones [ 0 ] ) ;
Assert . NotNull ( zone . Contents ) ;
var crafted = EntitySerializer . Items ( zone . Contents . Entities [ 0 ] ) . OfType < CraftedItemInstance > ( ) . ToArray ( ) ;
Assert . NotEmpty ( crafted ) ;
foreach ( var instance in crafted )
{
var lot = storedLots [ instance . Lot ] ;
Assert . Equal ( instance . Data . Key , lot . Design . Key ) ;
}
}
}
2026-09-17 04:17:13 +02:00
// A minimal, valid EntityPack with every collection field non-null, for tests that build packs directly rather
// than through EntitySerializer.Pack.
private static ShipPack BarePack ( EquippableItem hull , EntityPack [ ] children = null ,
2026-09-17 05:44:37 +02:00
( int2 , ItemInstance ) [ ] [ ] dockingBayContents = null ) = > new ShipPack
2026-09-17 04:17:13 +02:00
{
Name = "ship" ,
Hull = hull ,
Equipment = Array . Empty < ( int2 , EquippableItem ) > ( ) ,
CargoBays = Array . Empty < ( int2 , EquippableItem ) > ( ) ,
DockingBays = Array . Empty < ( int2 , EquippableItem ) > ( ) ,
CargoContents = Array . Empty < ( int2 , ItemInstance ) [ ] > ( ) ,
DockingBayContents = dockingBayContents ? ? Array . Empty < ( int2 , ItemInstance ) [ ] > ( ) ,
Children = children ? ? Array . Empty < EntityPack > ( ) ,
Temperature = new float [ 0 , 0 ] ,
Armor = new float [ 0 , 0 ] ,
Conductivity = new bool2 [ 0 , 0 ] ,
DockingBayAssignments = Array . Empty < int > ( ) ,
Settings = new EntitySettings ( ) ,
WeaponGroups = Array . Empty < int [ ] > ( )
} ;
2026-09-14 22:09:37 +02:00
// Zone seeds hash names with this; string.GetHashCode differs per process on .NET Core. The literal was computed
// once, from an independent transcription of CultMath's pcg over the UTF-8 bytes.
[Fact]
public void StableHashIsNotProcessRandomized ( ) = > Assert . Equal ( 2427892944 u , "Adrasteia" . StableHash ( ) ) ;
Land RunSave and the one-cache run and player lifecycle (Cut 10)
Owner: ActionGameManager composes the process's one cache. Its CultCache
getter attaches the catalog, GameData/run.cc and GameData/player.cc on
first access, in either scene, and nothing reopens it.
- RunSave (SavedGame.cs) replaces the SavedGame constructor:
- Capture builds the live run as plain documents and writes nothing. Zone
contents are `zone.Contents?.PackZone() ?? zone.PackedContents`, so a
zone generated earlier but not loaded this session keeps its contents
and its orbit and body records instead of being erased by the next save.
- Commit is the only writer of SavedGame and SavedZone: one Commit with
zone i at `savedzone-{i}`, SavedGame.Zones set to those refs, and every
stored SavedZone outside that set removed. Orbits and bodies staged by
zone generation land in the same single-file write.
- Clear removes every run record in one Commit.
- Run begins: New Game's first statement is RunSave.Clear. Run saves:
ActionGameManager.SaveRun at wormhole arrival and quit. Run ends: Die
clears the run, then saves settings.
- Run exists only as the run store's SavedGame global: MainMenu enables
Continue from it (and logs when the run store holds records but no
SavedGame), and StartGame reads it once to pick the new-run or resume
branch.
- PlayerSettings: the getter returns the player store's global and, on
first launch, commits GetDefaultPlayerSettings; it is the only creator.
SavePlayerSettings commits the instance.
- InputDisplayLayout reads its InputLayout from the catalog by the layout
asset's name and throws when the catalog has none.
Tests: new RunSaveTests (5): RepeatedSavesKeepRecordCountConstant,
SaveRemovesZonesNoLongerInTheRun, SaveWritesOnlyTheRunStore,
ResumeThenSaveKeepsUnloadedZoneContents, ClearRemovesEveryRunRecord. Each
fails with its rule mutated out.
Builds: headless, 11 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:00:27 +02:00
// As in AetherDb's loadout command: the settings an ItemManager needs to build units, and nothing authored.
internal static GameplaySettings TestSettings ( ) = > new GameplaySettings
{
DefaultEntitySettings = new EntitySettings ( ) ,
Tiers = new [ ] { new RarityTier { Name = "Common" , Quality = . 5f , Rarity = 0 , Color = new float3 ( 1 , 1 , 1 ) } } ,
QualityPriceModifier = new ExponentialLerp ( )
} ;
// Two orbits and a body upserted into the run store and left staged, as zone generation leaves them.
private static ZonePack StageZoneContents ( CultCache cache )
{
var root = cache . Upsert ( new OrbitData ( ) ) ;
var moon = cache . Upsert ( new OrbitData { Parent = root , Distance = 5 } ) ;
var body = cache . Upsert < BodyData > ( new PlanetData { Name = "Rock" , Orbit = moon } ) ;
return new ZonePack { Orbits = { root , moon } , Planets = { body } } ;
}
private static SavedGame Game ( CultCache cache ) = > new SavedGame
{
Factions = new [ ] { cache . RefOf ( cache . GetAll < Faction > ( ) . Single ( ) ) } ,
Relationships = new [ ] { FactionRelationship . Neutral } ,
HomeZones = new Dictionary < int , int > { { 0 , 0 } } ,
BossZones = new Dictionary < int , int > ( ) ,
DiscoveredZones = new [ ] { 0 } ,
ActionBarBindings = new SavedActionBarBinding [ 0 ] ,
Exit = - 1
} ;
// Fresh instances every call; zone contentsIndex holds the given contents, every other zone was never visited.
private static SavedZone [ ] Zones ( int count , ZonePack contents , int contentsIndex ) = > Enumerable . Range ( 0 , count )
. Select ( i = > new SavedZone
{
Name = $"Zone {i}" ,
Position = new float2 ( i * 10 , 0 ) ,
AdjacentZones = Enumerable . Range ( 0 , count ) . Where ( j = > j ! = i ) . ToArray ( ) ,
Factions = new [ ] { 0 } ,
Owner = 0 ,
Contents = i = = contentsIndex ? contents : null
} )
. ToArray ( ) ;
private static int RunRecordCount ( CultCache cache ) = >
cache . AllStoredDocuments . Count ( stored = > RunSave . IsRunRecord ( stored . Descriptor . DocumentType ) ) ;
private static string [ ] SavedZoneKeys ( CultCache cache ) = > cache . AllStoredDocuments
. Where ( stored = > stored . Document is SavedZone )
. Select ( stored = > stored . Key . Value )
. OrderBy ( key = > key , StringComparer . Ordinal )
. ToArray ( ) ;
private static int RecordsIn ( string path ) = >
CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( path ) ) . Records . Count ( ) ;
private static string Hash ( string path ) = > Convert . ToHexString ( SHA256 . HashData ( File . ReadAllBytes ( path ) ) ) ;
}