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>
158 lines
6.0 KiB
C#
158 lines
6.0 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 AttackResolutionTests
|
|
{
|
|
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
|
|
|
[Fact]
|
|
public void AttemptAttack_RecordsHitOrMissAndDamage()
|
|
{
|
|
var (a, b) = MakeDuelists(seed: 0xABCDUL);
|
|
var enc = new Encounter(0xABCDUL, encounterId: 1, new[] { a, b });
|
|
var attack = a.AttackOptions[0];
|
|
|
|
var result = Resolver.AttemptAttack(enc, a, b, attack);
|
|
|
|
Assert.Equal(a.Id, result.AttackerId);
|
|
Assert.Equal(b.Id, result.TargetId);
|
|
Assert.InRange(result.D20Roll, 1, 20);
|
|
if (result.Hit)
|
|
Assert.InRange(result.DamageRolled, 1, attack.Damage.Max(isCrit: result.Crit));
|
|
else
|
|
Assert.Equal(0, result.DamageRolled);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttemptAttack_Natural1_AlwaysMissesEvenIfTotalBeatsAc()
|
|
{
|
|
var (attacker, _) = MakeDuelists(seed: 1UL);
|
|
// Build a target with AC so low that any d20 + bonus beats it.
|
|
var weakTarget = WeakDummy(id: 99);
|
|
var enc = new Encounter(1UL, 1, new[] { attacker, weakTarget });
|
|
// Pick an attack with a known small bonus we can reason about.
|
|
var atk = new AttackOption { Name = "Test", ToHitBonus = 5,
|
|
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
|
|
// Use a deterministic forced-d20 by replaying rolls until we observe a natural 1.
|
|
// Simpler: assert across many encounters that any natural 1 is logged as miss.
|
|
for (int s = 0; s < 200; s++)
|
|
{
|
|
var enc2 = new Encounter((ulong)s, encounterId: 17, new[] { attacker, weakTarget });
|
|
var r = Resolver.AttemptAttack(enc2, attacker, weakTarget, atk);
|
|
if (r.D20Roll == 1)
|
|
Assert.False(r.Hit, $"natural 1 must miss (seed {s}, total {r.AttackTotal} vs AC {r.TargetAc})");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AttemptAttack_Natural20_AlwaysHitsAndIsCrit()
|
|
{
|
|
var attacker = WeakDummy(id: 1);
|
|
// Target with AC sky-high so only natural 20 can hit.
|
|
var fortress = StrongDummy(id: 2);
|
|
var atk = new AttackOption { Name = "Test", ToHitBonus = 0,
|
|
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
|
|
for (int s = 0; s < 300; s++)
|
|
{
|
|
var enc = new Encounter((ulong)s, 17, new[] { attacker, fortress });
|
|
var r = Resolver.AttemptAttack(enc, attacker, fortress, atk);
|
|
if (r.D20Roll == 20)
|
|
{
|
|
Assert.True(r.Hit, "natural 20 must always hit");
|
|
Assert.True(r.Crit, "natural 20 must register as crit");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AttemptAttack_DamageReducesTargetHp()
|
|
{
|
|
var (a, b) = MakeDuelists(seed: 99UL);
|
|
int startHp = b.CurrentHp;
|
|
var enc = new Encounter(99UL, 1, new[] { a, b });
|
|
// Hammer until at least one hit lands so we can compare HP delta.
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
var r = Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
|
|
if (r.Hit)
|
|
{
|
|
Assert.Equal(startHp - r.DamageRolled, r.TargetHpAfter);
|
|
Assert.Equal(startHp - r.DamageRolled, b.CurrentHp);
|
|
return;
|
|
}
|
|
}
|
|
// 100 misses in a row is statistically impossible with our test combatants;
|
|
// if we ever see it the test should fail loudly to flag the regression.
|
|
Assert.Fail("Expected at least one hit in 100 attacks.");
|
|
}
|
|
|
|
[Fact]
|
|
public void AttemptAttack_LogsEntry()
|
|
{
|
|
var (a, b) = MakeDuelists(seed: 7UL);
|
|
var enc = new Encounter(7UL, 1, new[] { a, b });
|
|
int logBefore = enc.Log.Count;
|
|
Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
|
|
Assert.True(enc.Log.Count > logBefore);
|
|
}
|
|
|
|
[Fact]
|
|
public void Heal_ClampsToMaxHp()
|
|
{
|
|
var c = WeakDummy(id: 1);
|
|
c.CurrentHp = 5;
|
|
Resolver.Heal(c, 100);
|
|
Assert.Equal(c.MaxHp, c.CurrentHp);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyDamage_ClampsToZero()
|
|
{
|
|
var c = WeakDummy(id: 1);
|
|
Resolver.ApplyDamage(c, c.MaxHp + 50);
|
|
Assert.Equal(0, c.CurrentHp);
|
|
Assert.True(c.IsDown);
|
|
}
|
|
|
|
[Fact]
|
|
public void MakeSave_CountsProficiencyOnlyWhenProficient()
|
|
{
|
|
var c = MakeDuelists(seed: 1UL).a;
|
|
var enc = new Encounter(1UL, 1, new[] { c });
|
|
var profSave = Resolver.MakeSave(enc, c, SaveId.STR, dc: 100, isProficient: true);
|
|
var enc2 = new Encounter(1UL, 1, new[] { c });
|
|
var nonprofSave = Resolver.MakeSave(enc2, c, SaveId.STR, dc: 100, isProficient: false);
|
|
Assert.Equal(profSave.SaveBonus, nonprofSave.SaveBonus + c.ProficiencyBonus);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private (Combatant a, Combatant b) MakeDuelists(ulong seed)
|
|
{
|
|
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
|
|
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
|
|
var a = Combatant.FromNpcTemplate(brigand, id: 1, position: new Vec2(0, 0));
|
|
var b = Combatant.FromNpcTemplate(wolf, id: 2, position: new Vec2(1, 0));
|
|
return (a, b);
|
|
}
|
|
|
|
private Combatant WeakDummy(int id)
|
|
{
|
|
// Take a footpad and reset HP to a known small value for damage tests.
|
|
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
|
|
var c = Combatant.FromNpcTemplate(def, id, new Vec2(0, 0));
|
|
return c;
|
|
}
|
|
|
|
private Combatant StrongDummy(int id)
|
|
{
|
|
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_captain"); // AC 16
|
|
return Combatant.FromNpcTemplate(def, id, new Vec2(1, 0));
|
|
}
|
|
}
|