164 lines
7.0 KiB
C#
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|