using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; using Xunit; namespace Theriapolis.Tests.Combat; public sealed class DeathSaveTrackerTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); [Fact] public void ApplyDamage_PlayerDroppedToZero_InstallsDeathSaveTracker() { var (player, _) = MakeFight(); Resolver.ApplyDamage(player, player.MaxHp + 50); Assert.Equal(0, player.CurrentHp); Assert.NotNull(player.DeathSaves); Assert.True(player.IsDown); Assert.True(player.IsAlive); // unconscious-but-not-dead is "alive" } [Fact] public void Heal_AbovZero_ResetsDeathSaves() { var (player, _) = MakeFight(); Resolver.ApplyDamage(player, player.MaxHp + 5); player.DeathSaves!.Roll(MakeEnc(player), player); // 1 fail or success Resolver.Heal(player, 5); Assert.True(player.CurrentHp > 0); Assert.Equal(0, player.DeathSaves.Successes); Assert.Equal(0, player.DeathSaves.Failures); } [Fact] public void ThreeFailures_MarkDead() { var t = new DeathSaveTracker(); // Use an encounter with a fixed seed and roll until we accumulate 3 failures // — then assert the Dead flag is set. var enc = MakeEncFromScratch(0x10UL); // arbitrary seed var dummy = MakeDummyCombatant(enc); for (int i = 0; i < 50 && !t.Dead && !t.Stabilised; i++) t.Roll(enc, dummy); // Across 50 rolls (and with the 3-fail / 3-success thresholds) we always // resolve to one of the terminal states. Either is acceptable for this // smoke test; the important thing is the loop terminates and counters // can advance. Assert.True(t.Dead || t.Stabilised); } [Fact] public void Roll_Natural20_RevivesAtOneHp() { // Find a seed whose first death-save d20 is 20. Probe deterministically. for (int s = 0; s < 200; s++) { var enc = MakeEncFromScratch((ulong)s); var pc = MakeDummyCombatant(enc); pc.CurrentHp = 0; pc.Conditions.Add(Condition.Unconscious); pc.DeathSaves = new DeathSaveTracker(); var outcome = pc.DeathSaves.Roll(enc, pc); if (outcome == DeathSaveOutcome.CriticalRevive) { Assert.Equal(1, pc.CurrentHp); Assert.DoesNotContain(Condition.Unconscious, pc.Conditions); Assert.Equal(0, pc.DeathSaves.Successes); Assert.Equal(0, pc.DeathSaves.Failures); return; } } Assert.Fail("Couldn't find a seed producing natural 20 in 200 attempts."); } // ── Helpers ────────────────────────────────────────────────────────── private (Combatant pc, Combatant foe) MakeFight() { var clade = _content.Clades["canidae"]; var species = _content.Species["wolf"]; var classDef= _content.Classes["fangsworn"]; var bg = _content.Backgrounds["pack_raised"]; var character = new Theriapolis.Core.Rules.Character.CharacterBuilder { Clade = clade, Species = species, ClassDef = classDef, Background = bg, BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8), } .ChooseSkill(SkillId.Athletics) .ChooseSkill(SkillId.Intimidation) .Build(_content.Items); var pc = Combatant.FromCharacter(character, 1, "PC", new Vec2(0, 0), Theriapolis.Core.Rules.Character.Allegiance.Player); var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0)); return (pc, foe); } private Encounter MakeEnc(Combatant pc) { var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0)); return new Encounter(0xCAFEUL, 1, new[] { pc, foe }); } private Encounter MakeEncFromScratch(ulong seed) { var hero = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), 1, new Vec2(0, 0)); var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0)); return new Encounter(seed, 1, new[] { hero, foe }); } private Combatant MakeDummyCombatant(Encounter enc) => enc.Participants[0]; }