feat(combat): add activate_power_attack action and consume Up Close and Deadly deed

- New Action kind activate_power_attack: free action that sets
  state.power_attack = True when the combatant has the Power Attack feat;
  logged no-op otherwise. Routed via _execute_movement (free-action branch)
  to keep _execute under the C901 complexity ceiling.
- Consume deed_up_close_and_deadly in resolve_attack: the first attack
  resolved while the flag is set gains +1d6 if it is a ranged attack within
  30 ft; the flag is reset in all cases (benefit lost if out of range or
  not ranged). Extracted into _consume_up_close_and_deadly helper.
- Document activate_power_attack, corrected deed semantics, and the
  power_attack snapshot key in README.
- Add tests/test_engine_fixes.py (5 tests): PA activation, no-feat no-op,
  deed+ranged within 30ft, deed out-of-range, deed melee.
This commit is contained in:
2026-09-06 18:37:58 +02:00
parent e29c472a67
commit a9ccdd3a56
3 changed files with 215 additions and 3 deletions
+45
View File
@@ -489,6 +489,7 @@ class Action:
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
"inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack",
]
target_id: str | None = None
spell_name: str | None = None
@@ -1373,12 +1374,40 @@ class CombatEngine:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
target.hp -= damage
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus,
)
def _consume_up_close_and_deadly(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
dist_ft: int,
damage: int,
*,
hit: bool,
) -> int:
"""Consume the Up Close and Deadly deed on the first attack resolved.
The deed applies +1d6 to a ranged attack within 30 ft; the flag is
reset after the first attack in all cases (the benefit is lost if the
attack is out of range or not ranged).
"""
if not attacker.deed_up_close_and_deadly:
return damage
if hit and self._is_ranged_attack(weapon) and dist_ft <= _UP_CLOSE_RANGE_FT:
bonus = self._rng.roll_die(6)
damage += bonus
target.hp -= bonus
attacker.deed_up_close_and_deadly = False
return damage
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
@@ -1990,6 +2019,8 @@ class CombatEngine:
self._move(state, target)
elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif kind == "activate_power_attack":
self._execute_activate_power_attack(state)
def _execute_immediate(
self, state: CombatantState, target: CombatantState | None, action: Action
@@ -2011,6 +2042,20 @@ class CombatEngine:
elif target is not None:
self._execute_attack(state, target, "attack")
def _execute_activate_power_attack(self, state: CombatantState) -> None:
"""Activate Power Attack as a free action (requires the feat)."""
if not self._has_feat(state, "Power Attack"):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"activate_power_attack: no Power Attack feat"
)
return
state.power_attack = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"Power Attack activated"
)
def _check_reactions(self, event: ReactionEvent) -> None:
"""Offer an event to the reaction policy; execute the first immediate.