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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user