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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user