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>
This commit is contained in:
Meta Vens 2026-09-14 22:00:27 +02:00
commit 95e164adce
5 changed files with 328 additions and 8 deletions

View file

@ -3,7 +3,6 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using System;
using System.Collections;
using System.Collections.Generic;
@ -49,12 +48,36 @@ public class ActionGameManager : MonoBehaviour
{
if (_cultCache != null) return _cultCache;
_cultCache = AetheriaStores.Open(Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"));
// All three stores attach once and stay attached until the process exits. The run lifecycle is record-level
// inside this cache; reopening would replace the catalog instances the galaxy and live entities hold.
_cultCache = AetheriaStores.Open(
Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"),
runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"),
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"));
return _cultCache;
}
}
// The only creator of PlayerSettings: an absent global means first launch, and the defaults are committed.
public static PlayerSettings PlayerSettings
{
get
{
var settings = CultCache.GetGlobal<PlayerSettings>();
if (settings != null) return settings;
settings = GetDefaultPlayerSettings();
CultCache.Commit(batch => batch.Upsert(settings));
return settings;
}
}
public static void SavePlayerSettings()
{
var settings = PlayerSettings;
CultCache.Commit(batch => batch.Upsert(settings));
}
private static PlayerSettings GetDefaultPlayerSettings()
{
var settings = new PlayerSettings();
@ -205,6 +228,23 @@ public class ActionGameManager : MonoBehaviour
get => MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(Settings.GameplaySettings.DefaultEntitySettings));
}
private void OnApplicationQuit()
{
SaveRun();
SavePlayerSettings();
}
// Wormhole arrival and quit save the run; a dead run has no galaxy, so it is not saved.
public void SaveRun()
{
if (CurrentGalaxy != null)
{
var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, IsTutorial,
_actionBarSlots.Select(s => s.Save()).ToArray());
RunSave.Commit(CultCache, game, zones);
}
}
private void OnDisable()
{
Input.Dispose();
@ -561,7 +601,7 @@ public class ActionGameManager : MonoBehaviour
ship.ExitWormhole(ZoneRenderer.WormholeInstances.Keys.First(w => w.Target == oldZone.GalaxyZone).Position,
Settings.GameplaySettings.WormholeExitVelocity * ItemManager.Random.NextFloat2Direction());
CurrentEntity.Zone = Zone;
SaveState();
SaveRun();
};
}
@ -655,7 +695,8 @@ public class ActionGameManager : MonoBehaviour
{
if (CurrentGalaxy != null)
{
if (PlayerSettings.SavedRun == null)
var saved = CultCache.GetGlobal<SavedGame>();
if (saved == null)
{
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
PopulateLevel(CurrentGalaxy.Entrance);
@ -678,8 +719,8 @@ public class ActionGameManager : MonoBehaviour
foreach(var group in CurrentGalaxy.DiscoveredZones
.GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance]))
SectorMap.QueueZoneReveal(group);
PopulateLevel(CurrentGalaxy.Zones[PlayerSettings.SavedRun.CurrentZone]);
var targetEntity = Zone.Entities[PlayerSettings.SavedRun.CurrentZoneEntity];
PopulateLevel(CurrentGalaxy.Zones[saved.CurrentZone]);
var targetEntity = Zone.Entities[saved.CurrentZoneEntity];
if (targetEntity is OrbitalEntity orbitalEntity)
{
CurrentEntity = targetEntity.Children.First(c => c is Ship {IsPlayerShip: true});
@ -693,7 +734,7 @@ public class ActionGameManager : MonoBehaviour
for (var i = 0; i < _actionBarSlots.Count; i++)
{
_actionBarSlots[i].Restore(PlayerSettings.SavedRun.ActionBarBindings[i], CurrentEntity);
_actionBarSlots[i].Restore(saved.ActionBarBindings[i], CurrentEntity);
}
}
}
@ -1015,6 +1056,7 @@ public class ActionGameManager : MonoBehaviour
MainMenu.gameObject.SetActive(true);
Menu.gameObject.SetActive(false);
CurrentGalaxy = null;
RunSave.Clear(CultCache);
SavePlayerSettings();
Observable.EveryUpdate()
.Where(_ => Time.time - deathTime < DeathPostTransitionTime)

View file

@ -47,7 +47,84 @@ public class SavedGame
[Key(12)]
public FactionRelationship[] Relationships;
}
// The run's lifecycle over the run store. Only Commit creates SavedGame and SavedZone records.
public static class RunSave
{
// The live run as plain documents. Writes nothing.
public static (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy, Zone currentZone,
Entity currentEntity, bool isTutorial, SavedActionBarBinding[] actionBar)
{
var factions = galaxy.HomeZones.Keys.ToArray();
var game = new SavedGame
{
DiscoveredZones = galaxy.DiscoveredZones.Select(dz => Array.IndexOf(galaxy.Zones, dz)).ToArray(),
Background = galaxy.Background,
Factions = factions.Select(f => cache.RefOf(f)).ToArray(),
Relationships = galaxy.Factions.Select(f => galaxy.FactionRelationships[f]).ToArray(),
HomeZones = galaxy.HomeZones.ToDictionary(
x => Array.IndexOf(factions, x.Key),
x => Array.IndexOf(galaxy.Zones, x.Value)),
BossZones = galaxy.BossZones.ToDictionary(
x => Array.IndexOf(factions, x.Key),
x => Array.IndexOf(galaxy.Zones, x.Value)),
CurrentZone = Array.FindIndex(galaxy.Zones, zone => zone.Contents == currentZone),
CurrentZoneEntity = currentZone.Entities.IndexOf(currentEntity),
Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance),
Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit),
IsTutorial = isTutorial,
ActionBarBindings = actionBar
};
// A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over.
var zones = galaxy.Zones.Select(zone => new SavedZone
{
Name = zone.Name,
Position = zone.Position,
AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(),
Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(),
Contents = zone.Contents?.PackZone() ?? zone.PackedContents,
Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner)
}).ToArray();
return (game, zones);
}
// The only writer of SavedGame and SavedZone: one Commit to the run store. Zone i lands at savedzone-{i}, stable
// because a run's zone array is fixed at generation, and every stored SavedZone outside that set is removed. The
// run store's single-file commit writes its whole view, so orbits and bodies staged by zone generation land too.
public static void Commit(CultCache cache, SavedGame game, IReadOnlyList<SavedZone> zones)
{
var keys = zones.Select((_, i) => new CultRecordKey($"savedzone-{i}")).ToArray();
var kept = new HashSet<CultRecordKey>(keys);
var stale = cache.AllStoredDocuments
.Where(stored => stored.Document is SavedZone && !kept.Contains(stored.Key))
.Select(stored => stored.Key)
.ToArray();
game.Zones = keys.Select(key => new CultRecordRef<SavedZone>(key)).ToArray();
cache.Commit(batch =>
{
for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), zones[i], keys[i]);
batch.Upsert(game);
foreach (var key in stale) batch.Remove(key);
});
}
// Removes every run record (SavedGame, SavedZone, OrbitData, BodyData) in one Commit.
public static void Clear(CultCache cache)
{
var run = cache.AllStoredDocuments
.Where(stored => IsRunRecord(stored.Descriptor.DocumentType))
.Select(stored => stored.Key)
.ToArray();
cache.Commit(batch =>
{
foreach (var key in run) batch.Remove(key);
});
}
public static bool IsRunRecord(Type documentType) =>
AetheriaStores.RunTypes.Any(home => home.IsAssignableFrom(documentType));
}
[CultDocument("aetheria.savedzone", "1"), MessagePackObject]

View file

@ -2,6 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using TMPro;
using UniRx;
using UnityEngine;
@ -81,6 +82,8 @@ public class InputDisplayLayout : MonoBehaviour
void Start()
{
_canvas = transform.root.GetComponent<Canvas>();
_inputLayout = ActionGameManager.CultCache.Get<InputLayout>(new CultRecordKey(LayoutFile.name))
?? throw new InvalidOperationException($"catalog has no InputLayout '{LayoutFile.name}'");
DisplayLayout(_inputLayout);
_buttonMappings.Add(MapMouseButton(MouseLeft, "<Mouse>/leftButton"));

View file

@ -2,6 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TMPro;
@ -91,9 +92,24 @@ public class MainMenu : MonoBehaviour
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus");
if (!InGame)
{
// A run exists only as the run store's SavedGame global.
var cache = ActionGameManager.CultCache;
var saved = cache.GetGlobal<SavedGame>();
if (saved == null && cache.AllStoredDocuments.Any(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType)))
Debug.Log("run store has no SavedGame; Continue disabled");
_nextMenu.panel.AddButton("Continue", saved == null ? (Action) null : () =>
{
ActionGameManager.IsTutorial = saved.IsTutorial;
ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log);
SceneManager.LoadScene("ARPG");
});
}
_nextMenu.panel.AddButton("New Game",
() =>
{
RunSave.Clear(ActionGameManager.CultCache);
var generatorState = "Loading Database Contents";
Action<string> setState = s => generatorState = s;

View file

@ -0,0 +1,182 @@
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;
// 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);
RunSave.Commit(cache, Game(cache), Zones(3, contents, 0));
}
using (var cache = Open())
{
Assert.Equal(7, RunRecordCount(cache));
Assert.Equal(7, RecordsIn(Run));
Assert.Equal(new[] { "savedzone-0", "savedzone-1", "savedzone-2" }, SavedZoneKeys(cache));
}
}
}
[Fact]
public void SaveRemovesZonesNoLongerInTheRun()
{
using (var cache = Open())
{
RunSave.Commit(cache, Game(cache), Zones(3, null, -1));
RunSave.Commit(cache, Game(cache), Zones(2, null, -1));
}
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())
RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0));
Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray());
Assert.Equal(7, RecordsIn(Run));
}
[Fact]
public void ResumeThenSaveKeepsUnloadedZoneContents()
{
using (var cache = Open())
RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 1));
using (var cache = Open())
{
var galaxy = new Galaxy(cache, cache.GetGlobal<SavedGame>(), _ => { });
var itemManager = new ItemManager(cache, TestSettings(), _ => { });
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]);
RunSave.Commit(cache, game, zones);
}
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()));
RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0));
}
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());
}
// 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)));
}