"""Tests for passive feats: Weapon Focus, Iron Will, Dodge, Point-Blank Shot.""" from __future__ import annotations from pf1e_simulator.abilities import ( ALERTNESS, DODGE, GREAT_FORTITUDE, IRON_WILL, LIGHTNING_REFLEXES, POINT_BLANK_SHOT, weapon_focus, ) from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.conditions import SHAKEN from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng def _make_hero( cid: str = "hero", *, features: list[object] | None = None, weapon_name: str = "sword", weapon_kind: str = "melee", attack_bonus: int = 5, damage_bonus: int = 2, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name=weapon_name, kind=weapon_kind, # type: ignore[arg-type] attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], damage_bonus=damage_bonus, range_increment_ft=60 if weapon_kind == "ranged" else None, ) return Combatant( id=cid, name=cid, level=4, size="Medium", abilities=AbilityScores( str_score=14, dex_score=12, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=20, ac=ACProfile(total=18, touch=12, flat_footed=16), bab=5, initiative_mod=2, speed_land_ft=30, saves=Saves(fort=5, ref=4, will=3), attacks=[attack], features=features or [], # type: ignore[arg-type] ) def _make_foe(cid: str = "foe") -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="club", kind="melee", attack_bonus=3, damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])], ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=12, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=15, ac=ACProfile(total=15, touch=10, flat_footed=13), bab=3, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=3, ref=2, will=1), attacks=[attack], ) def _make_engine( hero: Combatant, foe: Combatant, queue: list[int] ) -> tuple[CombatEngine, CombatantState, CombatantState]: h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max) f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max) legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) grid = Grid.from_spec(spec) engine = CombatEngine(ScriptedRng(queue), grid, [h, f]) return engine, h, f # ── AbilitySpec data ───────────────────────────────────────────────────────── class TestAbilityData: """Given: the predefined feat constants When: inspecting their effects Then: they match the CRB rules.""" def test_iron_will_gives_plus2_will(self) -> None: assert len(IRON_WILL.effects) == 1 mod = IRON_WILL.effects[0] assert mod.target == "will" assert mod.value == 2 def test_great_fortitude_gives_plus2_fort(self) -> None: assert GREAT_FORTITUDE.effects[0].target == "fort" assert GREAT_FORTITUDE.effects[0].value == 2 def test_lightning_reflexes_gives_plus2_ref(self) -> None: assert LIGHTNING_REFLEXES.effects[0].target == "ref" assert LIGHTNING_REFLEXES.effects[0].value == 2 def test_dodge_gives_plus1_dodge_ac(self) -> None: assert len(DODGE.effects) == 1 mod = DODGE.effects[0] assert mod.target == "ac" assert mod.value == 1 assert mod.bonus_type == "dodge" def test_weapon_focus_targets_specific_weapon(self) -> None: wf = weapon_focus("Pistol") assert wf.effects[0].target == "attack" assert wf.effects[0].value == 1 assert wf.effects[0].weapon_filter == "Pistol" def test_point_blank_shot_is_conditional(self) -> None: mods = POINT_BLANK_SHOT.effects assert len(mods) == 2 assert all(m.condition is not None for m in mods) assert {m.target for m in mods} == {"attack", "damage"} # ── Feat applied to attack rolls ───────────────────────────────────────────── class TestFeatOnAttack: """Given: an attacker with a passive feat When: resolve_attack is called Then: the feat modifier is applied to the attack total.""" def test_weapon_focus_adds_plus1_attack(self) -> None: hero = _make_hero(features=[weapon_focus("sword")]) foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) result = engine.resolve_attack(h, f, hero.attacks[0]) # base_bonus=5, roll=10, attack_mod=+1 (weapon focus) assert result.total == 10 + 5 + 1 # 16 def test_weapon_focus_does_not_apply_to_other_weapon(self) -> None: hero = _make_hero(features=[weapon_focus("Pistol")], weapon_name="sword") foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) result = engine.resolve_attack(h, f, hero.attacks[0]) # Weapon Focus (Pistol) doesn't apply to "sword" assert result.total == 10 + 5 # 15 def test_no_feats_normal_attack(self) -> None: hero = _make_hero() foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) result = engine.resolve_attack(h, f, hero.attacks[0]) assert result.total == 10 + 5 # 15 # ── Feat applied to AC ─────────────────────────────────────────────────────── class TestFeatOnAC: """Given: a defender with the Dodge feat When: resolve_attack is called Then: +1 dodge AC is applied.""" def test_dodge_adds_plus1_ac(self) -> None: defender = _make_foe("defender") defender_with_dodge = defender.model_copy(update={"features": [DODGE]}) hero = _make_hero() engine, h, d = _make_engine(hero, defender_with_dodge, [10, 4]) result = engine.resolve_attack(h, d, hero.attacks[0]) assert result.ac == 16 # 15 base + 1 dodge # ── Feat applied to saving throws ──────────────────────────────────────────── class TestFeatOnSaves: """Given: a combatant with save feats When: resolve_save is called Then: the feat modifier is applied to the save total.""" def test_iron_will_adds_plus2_will(self) -> None: hero = _make_hero(features=[IRON_WILL]) engine, h, _ = _make_engine(hero, _make_foe(), [10]) result = engine.resolve_save(h, "will", dc=12) # base will=3, roll=10, +2 (Iron Will) = 15 assert result.total == 15 assert result.success is True def test_great_fortitude_adds_plus2_fort(self) -> None: hero = _make_hero(features=[GREAT_FORTITUDE]) engine, h, _ = _make_engine(hero, _make_foe(), [10]) result = engine.resolve_save(h, "fort", dc=14) # base fort=5, roll=10, +2 = 17 assert result.total == 17 def test_lightning_reflexes_adds_plus2_ref(self) -> None: hero = _make_hero(features=[LIGHTNING_REFLEXES]) engine, h, _ = _make_engine(hero, _make_foe(), [10]) result = engine.resolve_save(h, "ref", dc=13) # base ref=4, roll=10, +2 = 16 assert result.total == 16 def test_iron_will_does_not_affect_fort(self) -> None: hero = _make_hero(features=[IRON_WILL]) engine, h, _ = _make_engine(hero, _make_foe(), [10]) result = engine.resolve_save(h, "fort", dc=20) # base fort=5, roll=10, no modifier = 15 assert result.total == 15 # ── Feats + conditions together ────────────────────────────────────────────── class TestFeatsWithConditions: """Given: a combatant with both a feat and a condition When: resolve_attack is called Then: both modifiers are collected and stacking rules apply.""" def test_weapon_focus_and_shaken(self) -> None: hero = _make_hero(features=[weapon_focus("sword")]) foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) h.conditions.append(SHAKEN) # -2 attack (untyped) # +1 (Weapon Focus, untyped) - 2 (shaken, untyped) = -1 result = engine.resolve_attack(h, f, hero.attacks[0]) assert result.total == 10 + 5 - 1 # 14 def test_dodge_ac_on_defender(self) -> None: hero = _make_hero(features=[DODGE]) foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) result = engine.resolve_attack(f, h, foe.attacks[0]) assert result.ac == 19 # 18 base + 1 dodge def test_multiple_save_feats_stack_untyped(self) -> None: hero = _make_hero(features=[IRON_WILL, ALERTNESS]) engine, h, _ = _make_engine(hero, _make_foe(), [10]) result = engine.resolve_save(h, "will", dc=17) assert result.total == 17 # 10 + 3 + 2 + 2