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; public sealed class FeatureProcessorTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Unarmored Defense (Feral) ────────────────────────────────────── [Fact] public void FeralUnarmoredDefense_RaisesAcWithoutBodyArmor() { var c = MakeChar("feral", "ursidae", "brown_bear", new AbilityScores(15, 14, 14, 10, 10, 8)); // Brown bear doesn't auto-equip body armor in feral kit (hide_vest is the only one) — // but the kit DOES equip hide_vest. Strip it and re-check. var body = c.Inventory.GetEquipped(EquipSlot.Body); if (body is not null) c.Inventory.TryUnequip(EquipSlot.Body, out _); int unarmoredAc = DerivedStats.ArmorClass(c); // Feral CON 14 → +2; DEX 13 (after wolf-folk -1, brown_bear baseline) → varies. Just assert the floor: 10 + DEX + CON ≥ 12. Assert.True(unarmoredAc >= 11, $"Feral unarmored AC should be at least 11, got {unarmoredAc}"); } // ── Sentinel Stance (Bulwark) ────────────────────────────────────── [Fact] public void SentinelStance_AddsTwoToAcDuringAttackResolution() { var enc = MakeMiniEncounter(out var attacker, out var target, attackerClass: "fangsworn", targetClass: "bulwark"); int beforeAc = target.ArmorClass; target.SentinelStanceActive = true; int withStance = target.ArmorClass + FeatureProcessor.ApplyAcBonus(target); Assert.Equal(beforeAc + 2, withStance); } [Fact] public void ToggleSentinelStance_FlipsFlagAndLogs() { var enc = MakeMiniEncounter(out _, out var bulwark, attackerClass: "feral", targetClass: "bulwark"); Assert.False(bulwark.SentinelStanceActive); bool ok = FeatureProcessor.ToggleSentinelStance(enc, bulwark); Assert.True(ok); Assert.True(bulwark.SentinelStanceActive); FeatureProcessor.ToggleSentinelStance(enc, bulwark); Assert.False(bulwark.SentinelStanceActive); } // ── Feral Rage ───────────────────────────────────────────────────── [Fact] public void TryActivateRage_ConsumesUseAndSetsFlag() { var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn"); var c = feral.SourceCharacter!; int usesBefore = c.RageUsesRemaining; bool ok = FeatureProcessor.TryActivateRage(enc, feral); Assert.True(ok); Assert.True(feral.RageActive); Assert.Equal(usesBefore - 1, c.RageUsesRemaining); } [Fact] public void TryActivateRage_NoUsesLeft_ReturnsFalse() { var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn"); feral.SourceCharacter!.RageUsesRemaining = 0; Assert.False(FeatureProcessor.TryActivateRage(enc, feral)); } [Fact] public void Rage_ResistsBludgeoningPiercingSlashing() { var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn"); feral.RageActive = true; Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Bludgeoning)); Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Piercing)); Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Slashing)); Assert.False(FeatureProcessor.IsResisted(feral, DamageType.Fire)); } [Fact] public void Rage_AddsDamageBonusOnMeleeOnly() { var enc = MakeMiniEncounter(out var feral, out var target, attackerClass: "feral", targetClass: "fangsworn"); feral.RageActive = true; var melee = new AttackOption { Name = "Test melee", Damage = new DamageRoll(0, 0, 0, DamageType.Slashing) }; int bonusMelee = FeatureProcessor.ApplyDamageBonus(enc, feral, target, melee, isCrit: false); Assert.Equal(2, bonusMelee); var ranged = new AttackOption { Name = "Test ranged", Damage = new DamageRoll(0, 0, 0, DamageType.Piercing), RangeShortTiles = 8, RangeLongTiles = 16 }; int bonusRanged = FeatureProcessor.ApplyDamageBonus(enc, feral, target, ranged, isCrit: false); Assert.Equal(0, bonusRanged); } // ── Sneak Attack (Shadow-Pelt) ──────────────────────────────────── [Fact] public void SneakAttack_FiresOncePerTurnWithFinesseWeapon() { // Shadow-Pelt's starting kit is thorn_blade (finesse) + studded_leather. var enc = MakeMiniEncounter(out var rogue, out var target, attackerClass: "shadow_pelt", targetClass: "fangsworn"); var attack = rogue.AttackOptions[0]; Assert.False(rogue.SneakAttackUsedThisTurn); int firstHit = FeatureProcessor.ApplyDamageBonus(enc, rogue, target, attack, isCrit: false); // After first hit, the flag should be set; second call returns no sneak bonus. Assert.True(rogue.SneakAttackUsedThisTurn); int secondHit = FeatureProcessor.ApplyDamageBonus(enc, rogue, target, attack, isCrit: false); Assert.True(firstHit > 0, "first finesse hit should add Sneak Attack damage"); // Second hit may still have other bonuses from non-rogue features but not Sneak Attack. // Assert second is strictly less than first (Sneak removed). Assert.True(secondHit < firstHit, $"second hit ({secondHit}) should be less than first ({firstHit}) — sneak attack consumed"); } [Fact] public void OnTurnStart_ResetsSneakAttackFlag() { var enc = MakeMiniEncounter(out var rogue, out _, attackerClass: "shadow_pelt", targetClass: "fangsworn"); rogue.SneakAttackUsedThisTurn = true; rogue.OnTurnStart(); Assert.False(rogue.SneakAttackUsedThisTurn); } // ── Fangsworn Duelist ───────────────────────────────────────────── [Fact] public void Duelist_AddsTwoDamage_OneHandedWeapon() { var enc = MakeMiniEncounter(out var fang, out var target, attackerClass: "fangsworn", targetClass: "fangsworn"); // Fangsworn starting kit: rend_sword + buckler (shield in offhand). Per Duelist spec // shield in off-hand is OK; only "no other weapon" matters. Assert.Equal("duelist", fang.SourceCharacter!.FightingStyle); var attack = fang.AttackOptions[0]; int bonus = FeatureProcessor.ApplyDamageBonus(enc, fang, target, attack, isCrit: false); Assert.True(bonus >= 2, $"Duelist should add at least 2 damage; got {bonus}"); } // ── Helpers ────────────────────────────────────────────────────── private Theriapolis.Core.Rules.Character.Character MakeChar(string classId, string cladeId, string speciesId, AbilityScores a) { var b = new CharacterBuilder { Clade = _content.Clades[cladeId], Species = _content.Species[speciesId], ClassDef = _content.Classes[classId], Background = _content.Backgrounds["pack_raised"], BaseAbilities = a, }; // Pick the right number of skills. 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 Encounter MakeMiniEncounter( out Combatant attacker, out Combatant target, string attackerClass = "fangsworn", string targetClass = "fangsworn") { var atkChar = MakeChar(attackerClass, "canidae", "wolf", new AbilityScores(15, 14, 13, 12, 10, 8)); var defChar = MakeChar(targetClass, "canidae", "wolf", new AbilityScores(15, 14, 13, 12, 10, 8)); attacker = Combatant.FromCharacter(atkChar, 1, "Attacker", new Vec2(0, 0), Theriapolis.Core.Rules.Character.Allegiance.Player); target = Combatant.FromCharacter(defChar, 2, "Target", new Vec2(1, 0), Theriapolis.Core.Rules.Character.Allegiance.Hostile); return new Encounter(0xABCDUL, 1, new[] { attacker, target }); } }