Files
TheriapolisV3/Theriapolis.Tests/Combat/DeathSaveTrackerTests.cs
T
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

117 lines
4.6 KiB
C#

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