using Theriapolis.Core; using Theriapolis.Core.Persistence; using Theriapolis.Core.Tactical; using Theriapolis.Core.Util; using Theriapolis.Core.World; using Xunit; namespace Theriapolis.Tests.Persistence; /// /// End-to-end M5 smoke test: /// 1. Generate a chunk via the streamer. /// 2. Mutate a tile (chop a tree → set Deco=None). /// 3. Flush → save → load. /// 4. Re-stream the chunk and verify the mutation is still applied. /// /// This is the single most important save-correctness test — exercises the /// streamer/delta-store/codec/restore loop in one shot. /// public sealed class DeltaPersistenceTests : IClassFixture { private const ulong TestSeed = 0xCAFEBABEUL; private readonly WorldCache _cache; public DeltaPersistenceTests(WorldCache c) => _cache = c; [Fact] public void ChopTree_PersistsAcrossSaveLoad() { var w = _cache.Get(TestSeed).World; // Find a chunk + tile that the baseline generator put a tree on. var (cc, lx, ly) = FindTreeTile(w); var deltasA = new InMemoryChunkDeltaStore(); var streamerA = new ChunkStreamer(TestSeed, w, deltasA); var chunk = streamerA.Get(cc); Assert.Equal(TacticalDeco.Tree, chunk.Tiles[lx, ly].Deco); // Chop it. chunk.Tiles[lx, ly].Deco = TacticalDeco.None; chunk.HasDelta = true; // Flush + save. streamerA.FlushAll(); var header = new SaveHeader { WorldSeedHex = $"0x{TestSeed:X}" }; var body = new SaveBody { Clock = new() { InGameSeconds = 0 } }; body.Player = new() { Name = "Tester" }; foreach (var kv in deltasA.All) body.ModifiedChunks[kv.Key] = kv.Value; var bytes = SaveCodec.Serialize(header, body); // ── Load on a fresh streamer ── var (_, rb) = SaveCodec.Deserialize(bytes); var deltasB = new InMemoryChunkDeltaStore(); foreach (var kv in rb.ModifiedChunks) deltasB.Put(kv.Key, kv.Value); var streamerB = new ChunkStreamer(TestSeed, w, deltasB); var reloaded = streamerB.Get(cc); Assert.Equal(TacticalDeco.None, reloaded.Tiles[lx, ly].Deco); } [Fact] public void UnmodifiedChunk_HasNoDelta() { var w = _cache.Get(TestSeed).World; var deltas = new InMemoryChunkDeltaStore(); var streamer = new ChunkStreamer(TestSeed, w, deltas); // Touch a chunk without modifying it. var cc = new ChunkCoord(20, 20); streamer.Get(cc); streamer.FlushAll(); Assert.Null(deltas.Get(cc)); } private static (ChunkCoord cc, int lx, int ly) FindTreeTile(WorldState w) { // Walk a band of chunks centred near a known land settlement so we // don't waste time scanning ocean. var anchor = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3); var centre = ChunkCoord.ForWorldTile(anchor.TileX, anchor.TileY); for (int dy = -3; dy <= 3; dy++) for (int dx = -3; dx <= 3; dx++) { var cc = new ChunkCoord(centre.X + dx, centre.Y + dy); var chunk = TacticalChunkGen.Generate(0xCAFEBABEUL, cc, w); for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++) for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++) if (chunk.Tiles[lx, ly].Deco == TacticalDeco.Tree) return (cc, lx, ly); } throw new Xunit.Sdk.XunitException("no tree near settlement to chop in test"); } }