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:
@@ -0,0 +1,159 @@
|
||||
"""Engine-level tests for the two audit fixes.
|
||||
|
||||
Covers:
|
||||
- ``activate_power_attack`` action kind: sets ``state.power_attack`` as a free
|
||||
action when the combatant has the Power Attack feat, and is a logged no-op
|
||||
otherwise.
|
||||
- ``deed_up_close_and_deadly`` consumption: the +1d6 bonus applies to the
|
||||
first ranged attack within 30 ft and the flag is reset; the flag is reset
|
||||
with no bonus when the first attack is out of range or not ranged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.abilities import POWER_ATTACK
|
||||
from pf1e_simulator.combat import Action, 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 _combatant(
|
||||
cid: str,
|
||||
*,
|
||||
kind: str = "melee",
|
||||
range_increment_ft: int | None = None,
|
||||
features: list | None = None,
|
||||
) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="weapon",
|
||||
kind=kind,
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
|
||||
range_increment_ft=range_increment_ft,
|
||||
)
|
||||
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=4,
|
||||
initiative_mod=2,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=6, ref=2, will=2),
|
||||
attacks=[attack],
|
||||
features=features or [],
|
||||
)
|
||||
|
||||
|
||||
def _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 _engine(
|
||||
attacker: Combatant, target: Combatant, queue: list[int]
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
a = CombatantState(combatant=attacker, side="players", pos=(0, 0), hp=attacker.hp_max)
|
||||
t = CombatantState(combatant=target, side="monsters", pos=(0, 0), hp=target.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, [a, t])
|
||||
return engine, a, t
|
||||
|
||||
|
||||
class TestActivatePowerAttack:
|
||||
"""Given: a combatant with the Power Attack feat
|
||||
When: an activate_power_attack action is executed
|
||||
Then: state.power_attack is set True and the activation is logged."""
|
||||
|
||||
def test_sets_flag_and_logs(self) -> None:
|
||||
attacker = _combatant("fighter", features=[POWER_ATTACK])
|
||||
engine, a, _ = _engine(attacker, _foe(), [])
|
||||
engine._execute(a, Action(kind="activate_power_attack"))
|
||||
assert a.power_attack is True
|
||||
assert any("Power Attack activated" in line for line in engine._transcript)
|
||||
|
||||
def test_without_feat_is_noop_with_warning(self) -> None:
|
||||
attacker = _combatant("fighter")
|
||||
engine, a, _ = _engine(attacker, _foe(), [])
|
||||
engine._execute(a, Action(kind="activate_power_attack"))
|
||||
assert a.power_attack is False
|
||||
assert any("no Power Attack feat" in line for line in engine._transcript)
|
||||
|
||||
|
||||
class TestDeedUpCloseAndDeadly:
|
||||
"""Given: an attacker with deed_up_close_and_deadly set
|
||||
When: an attack is resolved
|
||||
Then: the first ranged attack within 30 ft gains +1d6 and the flag resets;
|
||||
otherwise the flag resets with no bonus."""
|
||||
|
||||
def test_ranged_within_30ft_adds_d6_and_resets(self) -> None:
|
||||
attacker = _combatant("gunner", kind="ranged", range_increment_ft=60)
|
||||
target = _foe()
|
||||
engine, a, t = _engine(attacker, target, [15, 4, 3])
|
||||
a.deed_up_close_and_deadly = True
|
||||
result = engine.resolve_attack(a, t, attacker.attacks[0])
|
||||
assert result.hit is True
|
||||
assert result.damage == 4 + 3 # 1d8(4) + 1d6(3)
|
||||
assert a.deed_up_close_and_deadly is False
|
||||
|
||||
def test_out_of_range_resets_without_bonus(self) -> None:
|
||||
attacker = _combatant("gunner", kind="ranged", range_increment_ft=60)
|
||||
target = _foe()
|
||||
engine, a, t = _engine(attacker, target, [15, 4])
|
||||
a.deed_up_close_and_deadly = True
|
||||
t.pos = (10, 0) # 50 ft away, beyond the 30 ft deed range
|
||||
result = engine.resolve_attack(a, t, attacker.attacks[0])
|
||||
assert result.hit is True
|
||||
assert result.damage == 4 # no +1d6
|
||||
assert a.deed_up_close_and_deadly is False
|
||||
|
||||
def test_melee_attack_resets_without_bonus(self) -> None:
|
||||
attacker = _combatant("fighter")
|
||||
target = _foe()
|
||||
engine, a, t = _engine(attacker, target, [15, 4])
|
||||
a.deed_up_close_and_deadly = True
|
||||
result = engine.resolve_attack(a, t, attacker.attacks[0])
|
||||
assert result.hit is True
|
||||
assert result.damage == 4 # no +1d6 (not ranged)
|
||||
assert a.deed_up_close_and_deadly is False
|
||||
Reference in New Issue
Block a user