Files
TheriapolisV3/Theriapolis.Tests/Persistence/MidCombatSaveRoundTripTests.cs
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

164 lines
7.0 KiB
C#

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,
};
}
}