using Theriapolis.Core; using Theriapolis.Core.Entities; using Theriapolis.Core.Persistence; using Theriapolis.Core.Tactical; using Theriapolis.Core.Time; using Xunit; namespace Theriapolis.Tests.Persistence; /// /// SaveCodec round-trip: every field we write should reappear after Serialize /// → Deserialize. The Phase-4 reserved fields (Flags, Factions, etc.) are /// covered with at least one entry each so future schema bumps notice if a /// section gets accidentally dropped. /// public sealed class SaveCodecRoundTripTests { [Fact] public void Roundtrip_PreservesPlayerAndClock() { var header = new SaveHeader { WorldSeedHex = "0xDEADBEEF", PlayerName = "Grev", PlayerTier = 2, InGameSeconds = 12345, SavedAtUtc = "2026-04-21T09:00:00Z", }; header.StageHashes["ElevationGen"] = "0xABCDEF01"; header.StageHashes["BiomeAssign"] = "0x11223344"; var body = new SaveBody { Player = new PlayerActorState { Id = 7, Name = "Grev", PositionX = 1234.5f, PositionY = 678.9f, FacingAngleRad = MathF.PI * 0.5f, SpeedWorldPxPerSec = 80f, HighestTierReached = 2, DiscoveredPoiIds = new[] { 3, 14, 159 }, }, Clock = new WorldClockState { InGameSeconds = 12345 }, }; body.Flags["acts:1:complete"] = 1; var bytes = SaveCodec.Serialize(header, body); var (rh, rb) = SaveCodec.Deserialize(bytes); Assert.Equal(header.WorldSeedHex, rh.WorldSeedHex); Assert.Equal(header.PlayerName, rh.PlayerName); Assert.Equal(2, rh.StageHashes.Count); Assert.Equal("0xABCDEF01", rh.StageHashes["ElevationGen"]); Assert.Equal(7, rb.Player.Id); Assert.Equal("Grev", rb.Player.Name); Assert.Equal(1234.5f, rb.Player.PositionX); Assert.Equal(MathF.PI * 0.5f, rb.Player.FacingAngleRad); Assert.Equal(2, rb.Player.HighestTierReached); Assert.Equal(new[] { 3, 14, 159 }, rb.Player.DiscoveredPoiIds); Assert.Equal(12345L, rb.Clock.InGameSeconds); Assert.Equal(1, rb.Flags["acts:1:complete"]); } [Fact] public void Roundtrip_PreservesChunkDeltas() { var header = new SaveHeader { WorldSeedHex = "0x1" }; var body = new SaveBody { Player = new PlayerActorState { Name = "X" }, Clock = new WorldClockState { InGameSeconds = 0 }, }; var d = new ChunkDelta { SpawnsConsumed = true }; d.TileMods.Add(new TileMod(5, 7, TacticalSurface.Cobble, TacticalDeco.None, (byte)TacticalFlags.Road)); d.TileMods.Add(new TileMod(8, 8, TacticalSurface.Mud, TacticalDeco.Boulder, 0)); body.ModifiedChunks[new ChunkCoord(3, 4)] = d; var bytes = SaveCodec.Serialize(header, body); var (_, rb) = SaveCodec.Deserialize(bytes); Assert.True(rb.ModifiedChunks.ContainsKey(new ChunkCoord(3, 4))); var rd = rb.ModifiedChunks[new ChunkCoord(3, 4)]; Assert.True(rd.SpawnsConsumed); Assert.Equal(2, rd.TileMods.Count); Assert.Equal(TacticalSurface.Cobble, rd.TileMods[0].Surface); Assert.Equal(TacticalDeco.Boulder, rd.TileMods[1].Deco); } [Fact] public void Roundtrip_PreservesWorldTileDeltas() { var header = new SaveHeader { WorldSeedHex = "0x2" }; var body = new SaveBody { Player = new PlayerActorState { Name = "Y" }, Clock = new WorldClockState { InGameSeconds = 0 }, ModifiedWorldTiles = { new WorldTileDelta(50, 80, 7, 0xAB) }, }; var bytes = SaveCodec.Serialize(header, body); var (_, rb) = SaveCodec.Deserialize(bytes); Assert.Single(rb.ModifiedWorldTiles); Assert.Equal(50, rb.ModifiedWorldTiles[0].X); Assert.Equal(80, rb.ModifiedWorldTiles[0].Y); Assert.Equal(7, rb.ModifiedWorldTiles[0].NewBiome); Assert.Equal(0xAB, rb.ModifiedWorldTiles[0].NewFeatures); } [Fact] public void DeserializeHeaderOnly_DoesNotTouchBody() { var header = new SaveHeader { WorldSeedHex = "0xCAFE", PlayerName = "Solo" }; var body = new SaveBody { Player = new PlayerActorState { Id = 99, Name = "Solo" }, Clock = new WorldClockState { InGameSeconds = 1 }, }; var bytes = SaveCodec.Serialize(header, body); var only = SaveCodec.DeserializeHeaderOnly(bytes); Assert.Equal("0xCAFE", only.WorldSeedHex); Assert.Equal("Solo", only.PlayerName); } [Fact] public void Roundtrip_HandlesEmptyBody() { var header = new SaveHeader { WorldSeedHex = "0x0" }; var body = new SaveBody(); var bytes = SaveCodec.Serialize(header, body); var (rh, rb) = SaveCodec.Deserialize(bytes); Assert.Equal("0x0", rh.WorldSeedHex); Assert.Equal(0L, rb.Clock.InGameSeconds); Assert.Empty(rb.ModifiedChunks); Assert.Empty(rb.ModifiedWorldTiles); } [Fact] public void SchemaVersion_IsCurrent() { // Bumped to 8 in Phase 7 M0 (Phase 6.5 was 7; Phase 6 was 6; Phase 5 // was 5; Phase 4 was 4). The header auto-stamps the current schema // version on construction so a save written today carries the latest // version. Each bump must add a chained ISaveMigration in // . Assert.Equal(8, C.SAVE_SCHEMA_VERSION); var h = new SaveHeader(); Assert.Equal(C.SAVE_SCHEMA_VERSION, h.Version); } }