Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

153 lines
5.7 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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
// <see cref="Theriapolis.Core.Persistence.SaveMigrations.Migrations"/>.
Assert.Equal(8, C.SAVE_SCHEMA_VERSION);
var h = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, h.Version);
}
}