From 97c0431479a236f8dffa3f093cf2cce8ceb1f0db Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] 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 --- README.md | 8 +- pyproject.toml | 2 +- src/pf1e_simulator/abilities.py | 10 +- src/pf1e_simulator/combat.py | 18 ++- tests/test_power_attack.py | 236 ++++++++++++++++++++++++++++++++ 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 tests/test_power_attack.py diff --git a/README.md b/README.md index 47d529c..f97120e 100644 --- a/README.md +++ b/README.md @@ -379,9 +379,11 @@ dans la résolution) : à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de sauvegarde) via `_stat_modifiers` qui collecte effets + conditions. - `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec - catégorie feat/class/racial/trait) et dons passifs prédéfinis (Weapon Focus, + catégorie feat/class/racial/trait), dons passifs prédéfinis (Weapon Focus, Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness, - Point-Blank Shot, Dodge). Les modificateurs sont collectés par + Point-Blank Shot, Dodge) et don actif Power Attack (−X attaque / +2X dégâts, + X = BAB//4 + 1, mêlée uniquement, declared as free action at turn start via + `state.power_attack`). Les modificateurs sont collectés par `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ `features` de `Combatant` contient les aptitudes permanentes. - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`, @@ -399,7 +401,7 @@ dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 293 tests +uv run pytest -q # 307 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/pyproject.toml b/pyproject.toml index dff66c8..7ce8e30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ allowed-confusables = ["σ"] [tool.ruff.lint.per-file-ignores] # Tests: assert is expected, magic numbers are fine, annotations are noisy, # parametrized golden tests legitimately take many arguments -"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917", "ARG001"] +"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917", "ARG001", "SLF001", "RUF059"] # rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography "src/pf1e_simulator/rng.py" = ["S311"] # cli.py: stdout printing is the point of a CLI diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index 6d35053..30a68d9 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -139,5 +139,11 @@ DODGE = AbilitySpec( ), ) -# Skill Focus: +3 to a skill (not yet modeled — skills deferred) -# Defined as a placeholder for future expansion. +# Power Attack: active feat — declare at turn start, -X atk / +2X dmg on all +# melee attacks this turn, where X = BAB//4 + 1 (min 1). No passive modifiers; +# the engine checks for this feat and the ``power_attack`` flag on state. +POWER_ATTACK = AbilitySpec( + name="Power Attack", + category="feat", + source_detail="feat lv1", +) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 9e71aa1..12d99f3 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -162,6 +162,7 @@ class CombatantState: effects: list[StatModifier] = field(default_factory=list) conditions: list[Condition] = field(default_factory=list) moved_this_turn: bool = False + power_attack: bool = False @property def active(self) -> bool: @@ -266,6 +267,7 @@ class CombatEngine: """Execute one combatant's full turn; return True if the battle is over.""" state.effects.clear() state.moved_this_turn = False + state.power_attack = False actions = self._policy(self, state) before = len(self._transcript) for action in actions: @@ -378,6 +380,16 @@ class CombatEngine: mods.append(m) return mods + def _has_feat(self, state: CombatantState, feat_name: str) -> bool: + """True if the combatant has a feature with the given name.""" + return any(f.name == feat_name for f in state.combatant.features) + + def _power_attack_amt(self, attacker: CombatantState) -> int: + """Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive.""" + if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"): + return 0 + return max(1, attacker.combatant.bab // 4 + 1) + def resolve_attack( self, attacker: CombatantState, @@ -394,7 +406,8 @@ class CombatEngine: base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name) attack_mod = resolve_modifiers(atk_mods) - total = roll + base_bonus + penalty + flank + attack_mod + pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 + total = roll + base_bonus + penalty + flank + attack_mod - pa_amt ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac")) occupied = frozenset( s.pos for s in self._states if s.active and s is not attacker and s is not target @@ -413,11 +426,12 @@ class CombatEngine: if hit: if roll != _NATURAL_ONE and roll >= weapon.crit_range: confirm = self._rng.d20() - confirm_total = confirm + base_bonus + penalty + flank + attack_mod + confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt crit = confirm != _NATURAL_ONE and confirm_total >= ac dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name) damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus damage += resolve_modifiers(dmg_mods) + damage += pa_amt * 2 if weapon.kind == "melee" else 0 if crit: damage *= weapon.crit_mult damage = self._apply_dr(damage, weapon, target) diff --git a/tests/test_power_attack.py b/tests/test_power_attack.py new file mode 100644 index 0000000..1052269 --- /dev/null +++ b/tests/test_power_attack.py @@ -0,0 +1,236 @@ +"""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