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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,93 @@
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;
/// <summary>
/// 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.
/// </summary>
public sealed class DeltaPersistenceTests : IClassFixture<WorldCache>
{
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");
}
}
@@ -0,0 +1,141 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6.5 M0 — level-up history + subclass + learned-features round-trip.
/// Save a character at level 4 (with subclass + ASI history); load; assert
/// every per-level delta survives.
/// </summary>
public sealed class LevelUpRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
private Character MakeWolfFangsworn()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 12, 13, 10, 13, 8),
Name = "Tester",
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
return b.Build();
}
private void LevelTo(Character c, int target)
{
while (c.Level < target)
{
int next = c.Level + 1;
ulong seed = 0xCAFE_F00D_CAFE_F00DUL ^ (ulong)next;
var r = LevelUpFlow.Compute(c, next, seed, takeAverage: true);
var ch = new LevelUpChoices { TakeAverageHp = true };
if (r.GrantsSubclassChoice && c.ClassDef.SubclassIds.Length > 0)
ch.SubclassId = c.ClassDef.SubclassIds[0];
if (r.GrantsAsiChoice)
ch.AsiAdjustments = new() { { AbilityId.STR, 2 } };
c.ApplyLevelUp(r, ch);
}
}
[Fact]
public void Character_AtLevel4_RoundTripsThroughCharacterCodec()
{
var c = MakeWolfFangsworn();
c.Xp = 6_500; // beyond level 4 threshold
LevelTo(c, target: 4);
Assert.Equal(4, c.Level);
Assert.NotEmpty(c.SubclassId); // L3 picker fired
Assert.NotEmpty(c.LearnedFeatureIds);
Assert.Equal(3, c.LevelUpHistory.Count); // L2, L3, L4
var snap = CharacterCodec.Capture(c);
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(4, restored.Level);
Assert.Equal(c.SubclassId, restored.SubclassId);
Assert.Equal(c.MaxHp, restored.MaxHp);
Assert.Equal(c.LearnedFeatureIds.Count, restored.LearnedFeatureIds.Count);
Assert.Equal(c.LearnedFeatureIds, restored.LearnedFeatureIds);
Assert.Equal(c.LevelUpHistory.Count, restored.LevelUpHistory.Count);
for (int i = 0; i < c.LevelUpHistory.Count; i++)
{
var a = c.LevelUpHistory[i];
var b = restored.LevelUpHistory[i];
Assert.Equal(a.Level, b.Level);
Assert.Equal(a.HpGained, b.HpGained);
Assert.Equal(a.HpWasAveraged, b.HpWasAveraged);
Assert.Equal(a.HpHitDieResult, b.HpHitDieResult);
Assert.Equal(a.SubclassChosen, b.SubclassChosen);
Assert.Equal(a.FeaturesUnlocked, b.FeaturesUnlocked);
Assert.Equal(a.AsiAdjustmentsKeys, b.AsiAdjustmentsKeys);
Assert.Equal(a.AsiAdjustmentsValues, b.AsiAdjustmentsValues);
}
// ASI raised STR — level-4 history entry should record +2 STR.
var lv4 = c.LevelUpHistory[^1];
Assert.Equal(4, lv4.Level);
Assert.Single(lv4.AsiAdjustmentsValues);
Assert.Equal(2, lv4.AsiAdjustmentsValues[0]);
// And the ability is preserved across the round-trip.
Assert.Equal(c.Abilities.STR, restored.Abilities.STR);
}
[Fact]
public void Character_AtLevel4_RoundTripsThroughBinarySaveCodec()
{
var c = MakeWolfFangsworn();
c.Xp = 6_500;
LevelTo(c, target: 4);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED" };
var body = new SaveBody { PlayerCharacter = CharacterCodec.Capture(c) };
body.Player.Id = 1;
body.Player.Name = "Tester";
var bytes = SaveCodec.Serialize(header, body);
var (h2, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(header.Version, h2.Version);
Assert.NotNull(body2.PlayerCharacter);
Assert.Equal(4, body2.PlayerCharacter!.Level);
Assert.NotEmpty(body2.PlayerCharacter.SubclassId);
Assert.Equal(3, body2.PlayerCharacter.LevelUpHistory.Length);
Assert.Equal(c.LearnedFeatureIds.Count, body2.PlayerCharacter.LearnedFeatureIds.Length);
}
[Fact]
public void V6Save_WithoutLevelUpFields_LoadsAsLevel1Character()
{
// Simulate a v6 save by writing a character without the v7 trailing
// fields. Easiest path: hand-construct a minimal PlayerCharacterState
// (the codec's EOS-check pattern handles missing trailing data on read).
var c = MakeWolfFangsworn();
var snap = CharacterCodec.Capture(c);
// Force the v7 fields to defaults to simulate a v6 save.
snap.SubclassId = "";
snap.LearnedFeatureIds = Array.Empty<string>();
snap.LevelUpHistory = Array.Empty<LevelUpRecordState>();
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(1, restored.Level);
Assert.Empty(restored.SubclassId);
Assert.Empty(restored.LevelUpHistory);
}
}
@@ -0,0 +1,163 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 5 plan §5: same (worldSeed, encounterId) → identical dice stream;
/// save mid-encounter, load, continue, byte-identical outcome.
///
/// We exercise the codec layer + Encounter.ResumeRolls — the live game's
/// CombatHUD wires these together but the determinism contract belongs to
/// the Core layer and tests independently of MonoGame.
/// </summary>
public sealed class MidCombatSaveRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EncounterState_RoundTripsThroughSaveCodec()
{
var enc = MakeEncounter(0xCAFEUL);
// Burn 5 d20s + 1 attack (mutates participants).
for (int i = 0; i < 5; i++) enc.RollD20();
var attacker = enc.Participants[0];
var target = enc.Participants[1];
Resolver.AttemptAttack(enc, attacker, target, attacker.AttackOptions[0]);
var snapshot = SnapshotEncounter(enc);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION };
var body = new SaveBody { ActiveEncounter = snapshot };
body.PlayerCharacter = new PlayerCharacterState(); // make body well-formed for codec
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.NotNull(body2.ActiveEncounter);
Assert.Equal(snapshot.EncounterId, body2.ActiveEncounter!.EncounterId);
Assert.Equal(snapshot.RollCount, body2.ActiveEncounter.RollCount);
Assert.Equal(snapshot.RoundNumber, body2.ActiveEncounter.RoundNumber);
Assert.Equal(snapshot.Combatants.Length, body2.ActiveEncounter.Combatants.Length);
for (int i = 0; i < snapshot.Combatants.Length; i++)
{
Assert.Equal(snapshot.Combatants[i].Id, body2.ActiveEncounter.Combatants[i].Id);
Assert.Equal(snapshot.Combatants[i].CurrentHp, body2.ActiveEncounter.Combatants[i].CurrentHp);
Assert.Equal(snapshot.Combatants[i].PositionX, body2.ActiveEncounter.Combatants[i].PositionX);
}
}
[Fact]
public void Encounter_ResumedAtRollCount_ProducesIdenticalDownstreamLog()
{
// Setup: build two identical encounters from the same seed.
var encA = MakeEncounter(0xDEADUL);
var encB = MakeEncounter(0xDEADUL);
Assert.Equal(encA.EncounterSeed, encB.EncounterSeed);
// Run encA for several attacks, then snapshot its rollcount.
var atkA = encA.Participants[0];
var defA = encA.Participants[1];
for (int i = 0; i < 3; i++)
Resolver.AttemptAttack(encA, atkA, defA, atkA.AttackOptions[0]);
int snapshotRollCount = encA.RollCount;
int snapshotHp = defA.CurrentHp;
// Resume encB at the same point — defB starts at full HP, copy the
// mutated HP from defA so they're at the same state.
encB.ResumeRolls(snapshotRollCount);
encB.Participants[1].CurrentHp = snapshotHp;
// Continue both encounters in lockstep and compare each roll outcome.
var atkB = encB.Participants[0];
var defB = encB.Participants[1];
for (int i = 0; i < 5; i++)
{
int hpBeforeA = defA.CurrentHp;
int hpBeforeB = defB.CurrentHp;
var resA = Resolver.AttemptAttack(encA, atkA, defA, atkA.AttackOptions[0]);
var resB = Resolver.AttemptAttack(encB, atkB, defB, atkB.AttackOptions[0]);
Assert.Equal(resA.D20Roll, resB.D20Roll);
Assert.Equal(resA.Hit, resB.Hit);
Assert.Equal(resA.DamageRolled, resB.DamageRolled);
Assert.Equal(defA.CurrentHp, defB.CurrentHp);
}
}
[Fact]
public void NpcRoster_RoundTripsThroughSaveCodec()
{
var roster = new NpcRosterState();
roster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = 5, ChunkY = -3,
KilledSpawnIndices = new[] { 1, 2, 7 },
});
roster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = 0, ChunkY = 0,
KilledSpawnIndices = new[] { 0 },
});
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION };
var body = new SaveBody { NpcRoster = roster };
body.PlayerCharacter = new PlayerCharacterState();
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(2, body2.NpcRoster.ChunkDeltas.Count);
Assert.Equal(5, body2.NpcRoster.ChunkDeltas[0].ChunkX);
Assert.Equal(-3, body2.NpcRoster.ChunkDeltas[0].ChunkY);
Assert.Equal(new[] { 1, 2, 7 }, body2.NpcRoster.ChunkDeltas[0].KilledSpawnIndices);
Assert.Equal(new[] { 0 }, body2.NpcRoster.ChunkDeltas[1].KilledSpawnIndices);
}
private Encounter MakeEncounter(ulong worldSeed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad")
with { DefaultAllegiance = "player" };
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
return new Encounter(worldSeed, encounterId: 7, new[] { hero, foe });
}
/// <summary>
/// Build an EncounterState from a live encounter — mirrors what
/// <see cref="Game.Screens.CombatHUDScreen.SnapshotForSave"/> does in
/// the game side, but inlined here so this test stays Core-only.
/// </summary>
private static EncounterState SnapshotEncounter(Encounter enc)
{
var snaps = new CombatantSnapshot[enc.Participants.Count];
for (int i = 0; i < enc.Participants.Count; i++)
{
var c = enc.Participants[i];
snaps[i] = new CombatantSnapshot
{
Id = c.Id,
Name = c.Name,
IsPlayer = c.SourceCharacter is not null,
NpcTemplateId = c.SourceTemplate?.Id ?? "",
CurrentHp = c.CurrentHp,
PositionX = c.Position.X,
PositionY = c.Position.Y,
Conditions = c.Conditions.Select(x => (byte)x).ToArray(),
};
}
var initOrder = new int[enc.InitiativeOrder.Count];
for (int i = 0; i < initOrder.Length; i++) initOrder[i] = enc.InitiativeOrder[i];
return new EncounterState
{
EncounterId = enc.EncounterId,
RollCount = enc.RollCount,
CurrentTurnIndex = enc.CurrentTurnIndex,
RoundNumber = enc.RoundNumber,
InitiativeOrder = initOrder,
Combatants = snaps,
};
}
}
@@ -0,0 +1,104 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Persistence;
public sealed class Phase5SaveRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Character_RoundTripsThroughCharacterCodec()
{
var c = MakeBasicCharacter();
// Add and equip an item so the codec hits the inventory + equip-slot path.
var sword = c.Inventory.Add(_content.Items["rend_sword"]);
c.Inventory.TryEquip(sword, EquipSlot.MainHand, out _);
c.Conditions.Add(Condition.Frightened);
c.ExhaustionLevel = 2;
c.CurrentHp = c.MaxHp - 3;
c.Xp = 142;
var snap = CharacterCodec.Capture(c);
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(c.Clade.Id, restored.Clade.Id);
Assert.Equal(c.Species.Id, restored.Species.Id);
Assert.Equal(c.ClassDef.Id, restored.ClassDef.Id);
Assert.Equal(c.Background.Id, restored.Background.Id);
Assert.Equal(c.Abilities.STR, restored.Abilities.STR);
Assert.Equal(c.Abilities.WIS, restored.Abilities.WIS);
Assert.Equal(c.Level, restored.Level);
Assert.Equal(c.Xp, restored.Xp);
Assert.Equal(c.MaxHp, restored.MaxHp);
Assert.Equal(c.CurrentHp, restored.CurrentHp);
Assert.Equal(c.ExhaustionLevel, restored.ExhaustionLevel);
Assert.Contains(Condition.Frightened, restored.Conditions);
Assert.Single(restored.Inventory.Items);
Assert.Equal("rend_sword", restored.Inventory.Items[0].Def.Id);
Assert.Equal(EquipSlot.MainHand, restored.Inventory.Items[0].EquippedAt);
Assert.Same(restored.Inventory.Items[0], restored.Inventory.GetEquipped(EquipSlot.MainHand));
// Skill set should match (set equality)
Assert.Equal(c.SkillProficiencies, restored.SkillProficiencies);
}
[Fact]
public void SaveBody_PlayerCharacter_RoundTripsThroughSaveCodec()
{
var c = MakeBasicCharacter();
var snap = CharacterCodec.Capture(c);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED" };
var body = new SaveBody { PlayerCharacter = snap };
// Player is not interesting for this test; clock + chunks empty.
body.Player.Id = 1;
body.Player.Name = "Tester";
var bytes = SaveCodec.Serialize(header, body);
var (h2, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(header.Version, h2.Version);
Assert.NotNull(body2.PlayerCharacter);
Assert.Equal(snap.CladeId, body2.PlayerCharacter!.CladeId);
Assert.Equal(snap.SpeciesId, body2.PlayerCharacter.SpeciesId);
Assert.Equal(snap.ClassId, body2.PlayerCharacter.ClassId);
Assert.Equal(snap.STR, body2.PlayerCharacter.STR);
Assert.Equal(snap.WIS, body2.PlayerCharacter.WIS);
Assert.Equal(snap.MaxHp, body2.PlayerCharacter.MaxHp);
}
[Fact]
public void SaveBody_NoCharacter_StillRoundTrips()
{
// Phase-4-style body with no character. Round-trips cleanly because
// TAG_CHARACTER is only written when PlayerCharacter is non-null.
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xDEAD" };
var body = new SaveBody();
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.Null(body2.PlayerCharacter);
}
private Character MakeBasicCharacter()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Roundtrip",
};
// Class.SkillsChoose = 2 for fangsworn
b.ChosenClassSkills.Add(SkillId.Athletics);
b.ChosenClassSkills.Add(SkillId.Intimidation);
return b.Build();
}
}
@@ -0,0 +1,152 @@
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);
}
}
@@ -0,0 +1,46 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 5 M2 refuses Phase-4 saves rather than auto-instantiating a default
/// Character. Verified via <see cref="SaveCodec.IsCompatible"/> +
/// <see cref="SaveCodec.IncompatibilityReason"/>.
/// </summary>
public sealed class V4ToV5MigrationTests
{
[Fact]
public void V4Header_IsRejectedAsIncompatible()
{
var header = new SaveHeader { Version = 4 };
Assert.False(SaveCodec.IsCompatible(header));
Assert.NotEmpty(SaveCodec.IncompatibilityReason(header));
}
[Fact]
public void V5Header_IsCompatible()
{
var header = new SaveHeader { Version = 5 };
Assert.True(SaveCodec.IsCompatible(header));
Assert.Empty(SaveCodec.IncompatibilityReason(header));
}
[Fact]
public void NewSaveHeader_AutoSetsCurrentSchemaVersion()
{
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
Assert.True(header.Version >= C.SAVE_SCHEMA_MIN_VERSION);
}
[Fact]
public void IncompatibilityReason_MentionsVersionInformation()
{
var header = new SaveHeader { Version = 3 };
var reason = SaveCodec.IncompatibilityReason(header);
Assert.Contains("v3", reason);
Assert.Contains("v" + C.SAVE_SCHEMA_MIN_VERSION, reason);
}
}
@@ -0,0 +1,74 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6 M2 — additive V5→V6 migration. Unlike V4→V5 (rejection), v5
/// saves are accepted and migrated up by zero-filling the new typed
/// reputation containers.
/// </summary>
public sealed class V5ToV6MigrationTests
{
[Fact]
public void V5Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 5 };
Assert.True(SaveCodec.IsCompatible(header),
"v5 must remain readable post-Phase-6 (MIN_VERSION = 5).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6_NewBody_HasEmptyReputationState()
{
var body = new SaveBody();
Assert.NotNull(body.ReputationState);
Assert.Empty(body.ReputationState.FactionStandings);
Assert.Empty(body.ReputationState.Personal);
Assert.Empty(body.ReputationState.Ledger);
}
[Fact]
public void V5SaveBody_AfterMigration_HasEmptyReputation()
{
// Construct what a Phase-5-saved body might look like — v5 fields
// (Player, Clock, ModifiedChunks, ModifiedWorldTiles, Flags,
// PlayerCharacter, NpcRoster, ActiveEncounter), no v6 fields.
var header = new SaveHeader { Version = 5 };
var body = new SaveBody
{
Player = new() { Name = "Old Hand", PositionX = 50, PositionY = 50 },
};
body.Flags["debug-flag"] = 1;
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
// Migration chains all the way up to the current schema version.
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// V5 fields must survive untouched.
Assert.Equal("Old Hand", body.Player.Name);
Assert.Equal(1, body.Flags["debug-flag"]);
// V6 fields must be empty (the migration does not synthesise data).
Assert.NotNull(body.ReputationState);
Assert.Empty(body.ReputationState.FactionStandings);
Assert.Empty(body.ReputationState.Personal);
}
[Fact]
public void NewSaveHeader_DefaultsToCurrentSchemaVersion()
{
// Schema version increments per phase (Phase 6 = v6, Phase 6.5 = v7);
// SaveHeader picks up the constant.
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
}
@@ -0,0 +1,71 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6.5 M0 — additive V6→V7 migration. Every v6 field carries over
/// unchanged; the new <see cref="PlayerCharacterState.SubclassId"/>,
/// <see cref="PlayerCharacterState.LearnedFeatureIds"/>, and
/// <see cref="PlayerCharacterState.LevelUpHistory"/> default-initialise
/// to empty values.
/// </summary>
public sealed class V6ToV7MigrationTests
{
[Fact]
public void V6Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 6 };
Assert.True(SaveCodec.IsCompatible(header),
"v6 must remain readable post-Phase-6.5 (MIN_VERSION = 5).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6SaveBody_AfterMigration_HasFreshLevelUpFields()
{
var header = new SaveHeader { Version = 6 };
var body = new SaveBody
{
Player = new() { Name = "Wanderer", PositionX = 10, PositionY = 10 },
PlayerCharacter = new()
{
CladeId = "canidae", SpeciesId = "wolf",
ClassId = "fangsworn", BackgroundId = "pack_raised",
STR = 15, DEX = 12, CON = 13, INT = 10, WIS = 13, CHA = 8,
Level = 1, Xp = 0, MaxHp = 11, CurrentHp = 11,
},
};
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// Existing fields untouched.
Assert.Equal("Wanderer", body.Player.Name);
Assert.Equal(1, body.PlayerCharacter!.Level);
// New v7 fields default-initialised to empty.
Assert.Equal("", body.PlayerCharacter.SubclassId);
Assert.Empty(body.PlayerCharacter.LearnedFeatureIds);
Assert.Empty(body.PlayerCharacter.LevelUpHistory);
}
[Fact]
public void NewSaveHeader_DefaultsToCurrentSchemaVersion()
{
// Phase 7 M0 bumped SAVE_SCHEMA_VERSION to 8; the old test
// hardcoded 7. This version-of-record test is an early-warning
// that future bumps remember to add a chained migration entry.
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
Assert.True(header.Version >= 7,
"Schema version must not regress below the v7 floor introduced in Phase 6.5.");
}
}
@@ -0,0 +1,71 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 7 M0 — additive V7→V8 migration. Phase-6.5 saves continue to
/// load post-Phase-7; the new <see cref="SaveBody"/> sections (anchors,
/// building deltas, dungeon state) default-initialise to empty, which
/// correctly represents "no anchors persisted, no buildings modified,
/// no dungeons visited" — the truth for any pre-Phase-7 save.
/// </summary>
public sealed class V7ToV8MigrationTests
{
[Fact]
public void V7Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 7 };
Assert.True(SaveCodec.IsCompatible(header),
"v7 must remain readable post-Phase-7 (MIN_VERSION = 5; v7 is one bump back).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6Header_ChainsThroughV7ToV8()
{
// v6 → v7 → v8 chain: a Phase-6 save should still load with two
// additive migrations applied in order.
var header = new SaveHeader { Version = 6 };
Assert.True(SaveCodec.IsCompatible(header));
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V7SaveBody_AfterMigration_PreservesPhase65Fields()
{
var header = new SaveHeader { Version = 7 };
var body = new SaveBody
{
Player = new() { Name = "Wanderer", PositionX = 10, PositionY = 10 },
PlayerCharacter = new()
{
CladeId = "canidae", SpeciesId = "wolf",
ClassId = "fangsworn", BackgroundId = "pack_raised",
STR = 15, DEX = 12, CON = 13, INT = 10, WIS = 13, CHA = 8,
Level = 3, Xp = 950, MaxHp = 28, CurrentHp = 28,
SubclassId = "pack_forged",
LearnedFeatureIds = new[] { "packmates_howl" },
},
};
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// Phase 6.5 fields untouched.
Assert.Equal(3, body.PlayerCharacter!.Level);
Assert.Equal("pack_forged", body.PlayerCharacter.SubclassId);
Assert.Contains("packmates_howl", body.PlayerCharacter.LearnedFeatureIds);
}
}