Every existing ItemManager/RunSave.Commit call site now threads a ProvenanceLedger. Five new tests and one rewrite cover the rulings this cut lands: a materialized ship's lots survive a real save and reload with unchanged quality and Attributed provenance; RunSave.Commit keeps only lots reachable from a save's packed entities, closing over Produced.Facility/Inputs, while the live ledger keeps everything; ProvenanceLedger's indexer and Reachable are loud on a missing lot; matching hardpoints mint from the same lot; Evaluate/GetTier read a lot's Quality and per-role fills; and Brand's first-in-key-order rule is asserted directly instead of through the old Product field. Two of these (MaterializedLotsSurviveSaveAndReload, CommitKeepsOnlyReachableLots) currently fail: committing any EntityPack into the run store and reopening it throws `TypeAccessException: No hash-resistant equality comparer available for type: CultMath.int2`, because EntityPack.PersistedBehaviors is a Dictionary<int2, ...> and CultMessagePackSecurity registers a collision-resistant comparer only for CultRecordRef<T>. This predates this cut (PersistedBehaviors is untouched here) and is a CultLib gap, not a defect in this schema; see the landing report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
11 KiB
C#
261 lines
11 KiB
C#
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<SavedGame>(), _ => { });
|
|
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<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), 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<SavedGame>());
|
|
}
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingLotIsLoud()
|
|
{
|
|
var ledger = new ProvenanceLedger();
|
|
Assert.Throws<InvalidOperationException>(() => ledger[0]);
|
|
Assert.Throws<InvalidOperationException>(() => 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<EntityPack>(),
|
|
PersistedBehaviors = new Dictionary<int2, PersistentBehaviorData[]>(),
|
|
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[]>()
|
|
};
|
|
|
|
// 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<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)));
|
|
}
|