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), new ProvenanceLedger()); } using (var cache = Open()) { Assert.Equal(8, RunRecordCount(cache)); Assert.Equal(8, 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), new ProvenanceLedger()); RunSave.Commit(cache, Game(cache), Zones(2, null, -1), new ProvenanceLedger()); } 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), new ProvenanceLedger()); Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); Assert.Equal(8, RecordsIn(Run)); } [Fact] public void ResumeThenSaveKeepsUnloadedZoneContents() { using (var cache = Open()) RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 1), new ProvenanceLedger()); using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); var itemManager = new ItemManager(cache, new ProvenanceLedger(), 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, RunSave.Lots(cache)); } using (var cache = Open()) { var unloaded = cache.Get(cache.GetGlobal().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), new ProvenanceLedger()); } 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()); } Assert.Equal(0, RecordsIn(Run)); Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); } // 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(), Factions = Array.Empty(), Owner = -1, Contents = new ZonePack { Entities = new List { 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); } } [Fact] public void MissingLotIsLoud() { var ledger = new ProvenanceLedger(); Assert.Throws(() => ledger[0]); Assert.Throws(() => ledger.Reachable(new[] { 9 })); } // 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, (int2, ItemInstance)[][] dockingBayContents = null) => new ShipPack { 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(), PersistedBehaviors = new Dictionary(), Temperature = new float[0, 0], Armor = new float[0, 0], Conductivity = new bool2[0, 0], DockingBayAssignments = Array.Empty(), Settings = new EntitySettings(), WeaponGroups = Array.Empty() }; // 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(2427892944u, "Adrasteia".StableHash()); // 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(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().Single()) }, Relationships = new[] { FactionRelationship.Neutral }, HomeZones = new Dictionary { { 0, 0 } }, BossZones = new Dictionary(), 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))); }