b451f83174
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>
117 lines
4.6 KiB
C#
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];
|
|
}
|