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; /// /// 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. /// 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 }); } /// /// Build an EncounterState from a live encounter — mirrors what /// does in /// the game side, but inlined here so this test stays Core-only. /// 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, }; } }