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