using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Items; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; using Xunit; namespace Theriapolis.Tests.Combat; /// /// Phase 7 M0 — wires four more L3 subclass features (Phase 6.5 carryover): /// - Antler-Guard "Retaliatory Strike": melee hit on a Sentinel-Stance /// antler-guard returns 1d8 + CON to the attacker. /// - Stampede-Heart "Trampling Charge": +1d8 bludgeoning on the first /// melee attack of each turn while raging. /// - Ambush-Artist "Opening Strike": +2d6 on the first melee attack of /// round 1 of an encounter. /// - Body-Wright "Combat Medic": Field Repair rolls 2d8 + INT (vs the /// base 1d8 + INT). /// /// Combined with the four Phase-6.5 wirings (Lone Fang, Herd-Wall, /// Pack-Forged, Blood Memory), this brings 8 of 16 L3 subclass features /// to live runtime. The remaining 8 are scaffolded but unwired and land /// in Phase 7 M1. /// public sealed class Phase7M0SubclassFeatureTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Antler-Guard Retaliatory Strike ────────────────────────────────── [Fact] public void AntlerGuard_RetaliatoryStrike_ReturnsDamageInSentinelStance() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "bulwark", pcSubclass: "antler_guard"); pc.SentinelStanceActive = true; int hostileHpBefore = hostile.CurrentHp; var attack = pc.AttackOptions[0]; int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, attack); Assert.True(dealt >= 1, "retaliatory strike must deal at least 1 damage"); Assert.Equal(hostileHpBefore - dealt, hostile.CurrentHp); } [Fact] public void AntlerGuard_RetaliatoryStrike_DoesNotFireWithoutSentinelStance() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "bulwark", pcSubclass: "antler_guard"); // SentinelStanceActive is false by default. int hostileHpBefore = hostile.CurrentHp; int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]); Assert.Equal(0, dealt); Assert.Equal(hostileHpBefore, hostile.CurrentHp); } [Fact] public void AntlerGuard_RetaliatoryStrike_DoesNotFireOnRangedHit() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "bulwark", pcSubclass: "antler_guard"); pc.SentinelStanceActive = true; int hostileHpBefore = hostile.CurrentHp; var rangedAttack = MakeRangedAttack(); int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, rangedAttack); Assert.Equal(0, dealt); Assert.Equal(hostileHpBefore, hostile.CurrentHp); } [Fact] public void AntlerGuard_RetaliatoryStrike_NoFire_IfNotAntlerGuard() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "bulwark", pcSubclass: "herd_wall"); pc.SentinelStanceActive = true; int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]); Assert.Equal(0, dealt); } // ── Stampede-Heart Trampling Charge ────────────────────────────────── [Fact] public void StampedeHeart_TramplingCharge_AddsDamage_FirstMeleeWhileRaging() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "feral", pcSubclass: "stampede_heart"); pc.RageActive = true; int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false); // Bonus = +2 (Rage) + 1d8 (Trampling Charge). Min 2 + 1 = 3, max 2 + 8 = 10. Assert.True(bonus >= 3 && bonus <= 10, $"expected [3..10], got {bonus}"); Assert.True(pc.TramplingChargeUsedThisTurn); } [Fact] public void StampedeHeart_TramplingCharge_OnlyFiresOncePerTurn() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "feral", pcSubclass: "stampede_heart"); pc.RageActive = true; var attack = pc.AttackOptions[0]; int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false); int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false); // First: rage (+2) + trampling (+1d8). Second: rage only (+2). Assert.True(first > second); Assert.Equal(2, second); } [Fact] public void StampedeHeart_TramplingCharge_DoesNotFireWithoutRage() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "feral", pcSubclass: "stampede_heart"); // RageActive false by default. int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false); Assert.Equal(0, bonus); } // ── Ambush-Artist Opening Strike ───────────────────────────────────── [Fact] public void AmbushArtist_OpeningStrike_AddsDamageInRoundOne() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "shadow_pelt", pcSubclass: "ambush_artist"); Assert.Equal(1, enc.RoundNumber); int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false); // Sneak attack 1d6 + Opening Strike 2d6 = 3d6. Range [3..18]. Assert.True(bonus >= 3 && bonus <= 18, $"expected [3..18], got {bonus}"); Assert.True(pc.OpeningStrikeUsed); } [Fact] public void AmbushArtist_OpeningStrike_OnlyFiresOncePerEncounter() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "shadow_pelt", pcSubclass: "ambush_artist"); var attack = pc.AttackOptions[0]; int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false); // Reset sneak-attack flag for cross-turn re-fire test. pc.SneakAttackUsedThisTurn = false; int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false); // First fires opening strike (3d6 = [3..18]); second only sneak attack (1d6 = [1..6]). Assert.True(first > second); Assert.True(second >= 1 && second <= 6, $"second attack should only be sneak attack [1..6], got {second}"); } // ── Body-Wright Combat Medic ───────────────────────────────────────── [Fact] public void BodyWright_FieldRepair_RollsTwoD8() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "claw_wright", pcSubclass: "body_wright"); // Establish the per-encounter pool the way PlayScreen would. FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!); // Damage the PC so the heal has somewhere to go. pc.CurrentHp = 1; bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc); Assert.True(ok); // Body-Wright heals 2d8 + INT — minimum 2 + INT, max 16 + INT. Since // healing clamps to MaxHp we just assert the heal exceeded the // 1d8 + INT base ceiling (8 + INT) at least *sometimes*. To make // this deterministic per our seed, assert HP gained ≥ 2 (the 2d8 floor). Assert.True(pc.CurrentHp >= 3, $"Body-Wright Combat Medic should heal ≥ 2 HP from a 2d8 roll (was at 1, now {pc.CurrentHp})"); } [Fact] public void NonBodyWright_FieldRepair_RollsOneD8() { var enc = MakeDuel(out var pc, out var hostile, pcClass: "claw_wright", pcSubclass: null); FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!); pc.CurrentHp = 1; bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc); Assert.True(ok); // Non-body-wright: 1d8 + INT = [1+INT..8+INT], capped to MaxHp. // Just assert heal ≤ 8 + INT (we don't care about INT exactly here). int intMod = pc.SourceCharacter!.Abilities.ModFor(AbilityId.INT); Assert.True(pc.CurrentHp <= 1 + 8 + intMod, $"Field Repair w/o Body-Wright caps at 1d8+INT = {1 + 8 + intMod}, got {pc.CurrentHp}"); } // ── Helpers ────────────────────────────────────────────────────────── private Encounter MakeDuel(out Combatant pc, out Combatant hostile, string pcClass, string? pcSubclass) { var pcChar = MakeChar(pcClass, new AbilityScores(15, 14, 13, 12, 10, 8)); if (pcSubclass is not null) pcChar.SubclassId = pcSubclass; pc = Combatant.FromCharacter(pcChar, 1, "PC", new Vec2(0, 0), Theriapolis.Core.Rules.Character.Allegiance.Player); var hostileChar = MakeChar("fangsworn", new AbilityScores(13, 12, 13, 10, 10, 8)); hostile = Combatant.FromCharacter(hostileChar, 2, "Hostile", new Vec2(3, 0), Theriapolis.Core.Rules.Character.Allegiance.Hostile); return new Encounter(0xCAFEUL, 1, new List { pc, hostile }); } private Theriapolis.Core.Rules.Character.Character MakeChar(string classId, AbilityScores a) { var b = new CharacterBuilder { Clade = _content.Clades["canidae"], Species = _content.Species["wolf"], ClassDef = _content.Classes[classId], Background = _content.Backgrounds["pack_raised"], BaseAbilities = a, }; int n = b.ClassDef.SkillsChoose; foreach (var raw in b.ClassDef.SkillOptions) { if (b.ChosenClassSkills.Count >= n) break; try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { } } return b.Build(_content.Items); } private static AttackOption MakeRangedAttack() => new AttackOption { Name = "ranged-test", Damage = new DamageRoll(1, 6, 0, DamageType.Piercing), ToHitBonus = 0, // Setting RangeShortTiles>0 flips IsRanged true via the derived prop. RangeShortTiles = 6, RangeLongTiles = 18, }; }