Files
pf1e-simulator/tests/test_power_attack.py
T
ctan 97c0431479 feat(abilities): add Power Attack active feat (-X atk / +2X dmg, melee only)
Power Attack is the first active feat: a free-action toggle declared at
turn start. When state.power_attack is True and the combatant has the
POWER_ATTACK feature, melee attacks apply -X to the attack roll and
+2X to damage, where X = BAB//4 + 1 (min 1). Ranged attacks are
unaffected. The flag is cleared at the start of each turn via
_take_turn, like other per-turn state.

- abilities.py: POWER_ATTACK constant (AbilitySpec, no passive effects)
- combat.py: _has_feat helper, _power_attack_amt, resolve_attack
  applies penalty to attack total + crit confirm, bonus to damage
- tests/test_power_attack.py: 14 tests (amount at BAB 1/4/8/12, flag
  false, feat missing, attack penalty, damage bonus, ranged unaffected,
  flag cleared on turn)
- pyproject.toml: add SLF001 + RUF059 to test per-file-ignores (white-box
  testing accesses private helpers, partial tuple unpacking)
- README.md: document Power Attack in abilities.py section, 307 tests
2026-08-17 22:49:50 +02:00

237 lines
8.3 KiB
Python

"""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