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