"""Tests for Power Attack active feat: -X attack / +2X damage toggle.""" from __future__ import annotations from pf1e_simulator.abilities import POWER_ATTACK from pf1e_simulator.combat import CombatantState, CombatEngine 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_fighter( cid: str = "fighter", *, bab: int = 4, attack_bonus: int = 5, damage_bonus: int = 2, features: list[object] | None = None, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="longsword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], damage_bonus=damage_bonus, ) return Combatant( id=cid, name=cid, level=4, size="Medium", abilities=AbilityScores( str_score=16, dex_score=12, con_score=14, int_score=10, wis_score=10, cha_score=10, ), hp_max=30, ac=ACProfile(total=18, touch=12, flat_footed=16), bab=bab, initiative_mod=2, speed_land_ft=30, saves=Saves(fort=6, ref=2, will=2), 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=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=20, ac=ACProfile(total=15, touch=10, flat_footed=13), bab=1, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=2, ref=1, will=0), attacks=[attack], ) def _make_engine( fighter: Combatant, foe: Combatant, queue: list[int] ) -> tuple[CombatEngine, CombatantState, CombatantState]: f = CombatantState(combatant=fighter, side="players", pos=(0, 0), hp=fighter.hp_max) t = 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, [f, t]) return engine, f, t class TestPowerAttackAmount: """Given: a combatant with Power Attack feat and various BAB values When: _power_attack_amt is called with power_attack=True Then: returns X = BAB//4 + 1 (min 1). At BAB +4 and every +4, X increases by 1.""" def test_bab_1_gives_x1(self) -> None: fighter = _make_fighter(bab=1, features=[POWER_ATTACK]) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = True assert engine._power_attack_amt(f) == 1 def test_bab_4_gives_x2(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = True assert engine._power_attack_amt(f) == 2 def test_bab_8_gives_x3(self) -> None: fighter = _make_fighter(bab=8, features=[POWER_ATTACK]) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = True assert engine._power_attack_amt(f) == 3 def test_bab_12_gives_x4(self) -> None: fighter = _make_fighter(bab=12, features=[POWER_ATTACK]) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = True assert engine._power_attack_amt(f) == 4 def test_flag_false_returns_zero(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = False assert engine._power_attack_amt(f) == 0 def test_feat_missing_returns_zero(self) -> None: fighter = _make_fighter(bab=4) engine, f, _ = _make_engine(fighter, _make_foe(), [10]) f.power_attack = True assert engine._power_attack_amt(f) == 0 class TestPowerAttackOnAttackRoll: """Given: an attacker with Power Attack active When: resolve_attack is called Then: the attack total is reduced by X.""" def test_bab_4_attack_reduced_by_2(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) f.power_attack = True result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.total == 15 + 5 - 2 # 18 def test_bab_8_attack_reduced_by_3(self) -> None: fighter = _make_fighter(bab=8, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) f.power_attack = True result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.total == 15 + 5 - 3 # 17 def test_no_power_attack_normal_total(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.total == 15 + 5 # 20 class TestPowerAttackOnDamage: """Given: an attacker with Power Attack active who hits When: resolve_attack resolves damage Then: damage is increased by 2X.""" def test_bab_4_damage_increased_by_4(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) f.power_attack = True result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.hit is True assert result.damage == 4 + 2 + 4 # 10 def test_bab_8_damage_increased_by_6(self) -> None: fighter = _make_fighter(bab=8, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) f.power_attack = True result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.hit is True assert result.damage == 4 + 2 + 6 # 12 def test_no_power_attack_normal_damage(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [15, 4]) result = engine.resolve_attack(f, t, fighter.attacks[0]) assert result.hit is True assert result.damage == 4 + 2 # 6 class TestPowerAttackOnlyMelee: """Given: an attacker with Power Attack active using a ranged weapon When: resolve_attack is called Then: Power Attack does not apply (melee only).""" def test_ranged_attack_unaffected(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) fighter_ranged = fighter.model_copy(update={ "attacks": [AttackSpec( id="fighter-w", name="bow", kind="ranged", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], range_increment_ft=60, )], }) foe = _make_foe() engine, f, t = _make_engine(fighter_ranged, foe, [15, 4]) f.power_attack = True result = engine.resolve_attack(f, t, fighter_ranged.attacks[0]) assert result.total == 15 + 5 # 20, no PA penalty if result.hit: assert result.damage == 4 # no PA bonus class TestPowerAttackClearedOnTurn: """Given: a combatant with power_attack=True When: _take_turn starts Then: power_attack is cleared to False.""" def test_cleared_at_turn_start(self) -> None: fighter = _make_fighter(bab=4, features=[POWER_ATTACK]) foe = _make_foe() engine, f, t = _make_engine(fighter, foe, [10, 10]) f.power_attack = True engine._take_turn(f) assert f.power_attack is False