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
This commit is contained in:
@@ -379,9 +379,11 @@ dans la résolution) :
|
|||||||
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
|
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
|
||||||
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions.
|
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions.
|
||||||
- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec
|
- `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,
|
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
|
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
|
||||||
`features` de `Combatant` contient les aptitudes permanentes.
|
`features` de `Combatant` contient les aptitudes permanentes.
|
||||||
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
|
- `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) :
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q # 293 tests
|
uv run pytest -q # 307 tests
|
||||||
uv run ruff check src tests
|
uv run ruff check src tests
|
||||||
uv run basedpyright src # mode strict
|
uv run basedpyright src # mode strict
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ allowed-confusables = ["σ"]
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
|
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
|
||||||
# parametrized golden tests legitimately take many arguments
|
# 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
|
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
|
||||||
"src/pf1e_simulator/rng.py" = ["S311"]
|
"src/pf1e_simulator/rng.py" = ["S311"]
|
||||||
# cli.py: stdout printing is the point of a CLI
|
# cli.py: stdout printing is the point of a CLI
|
||||||
|
|||||||
@@ -139,5 +139,11 @@ DODGE = AbilitySpec(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Skill Focus: +3 to a skill (not yet modeled — skills deferred)
|
# Power Attack: active feat — declare at turn start, -X atk / +2X dmg on all
|
||||||
# Defined as a placeholder for future expansion.
|
# 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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class CombatantState:
|
|||||||
effects: list[StatModifier] = field(default_factory=list)
|
effects: list[StatModifier] = field(default_factory=list)
|
||||||
conditions: list[Condition] = field(default_factory=list)
|
conditions: list[Condition] = field(default_factory=list)
|
||||||
moved_this_turn: bool = False
|
moved_this_turn: bool = False
|
||||||
|
power_attack: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
@@ -266,6 +267,7 @@ class CombatEngine:
|
|||||||
"""Execute one combatant's full turn; return True if the battle is over."""
|
"""Execute one combatant's full turn; return True if the battle is over."""
|
||||||
state.effects.clear()
|
state.effects.clear()
|
||||||
state.moved_this_turn = False
|
state.moved_this_turn = False
|
||||||
|
state.power_attack = False
|
||||||
actions = self._policy(self, state)
|
actions = self._policy(self, state)
|
||||||
before = len(self._transcript)
|
before = len(self._transcript)
|
||||||
for action in actions:
|
for action in actions:
|
||||||
@@ -378,6 +380,16 @@ class CombatEngine:
|
|||||||
mods.append(m)
|
mods.append(m)
|
||||||
return mods
|
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(
|
def resolve_attack(
|
||||||
self,
|
self,
|
||||||
attacker: CombatantState,
|
attacker: CombatantState,
|
||||||
@@ -394,7 +406,8 @@ class CombatEngine:
|
|||||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
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)
|
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||||
attack_mod = resolve_modifiers(atk_mods)
|
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"))
|
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||||
occupied = frozenset(
|
occupied = frozenset(
|
||||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
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 hit:
|
||||||
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
||||||
confirm = self._rng.d20()
|
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
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||||
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
|
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 = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||||
damage += resolve_modifiers(dmg_mods)
|
damage += resolve_modifiers(dmg_mods)
|
||||||
|
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
||||||
if crit:
|
if crit:
|
||||||
damage *= weapon.crit_mult
|
damage *= weapon.crit_mult
|
||||||
damage = self._apply_dr(damage, weapon, target)
|
damage = self._apply_dr(damage, weapon, target)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user