Files
pf1e-simulator/tests/test_engine_fixes.py
T

181 lines
6.4 KiB
Python

"""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 firearm attack within 30 ft and the flag is reset; the flag is reset
with no bonus when the first attack is out of range, not ranged, or not a
firearm.
"""
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,
firearm: bool = False,
) -> 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,
firearm=firearm,
)
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 firearm attack within 30 ft gains +1d6 and the flag
resets; otherwise the flag resets with no bonus (Ultimate Combat deed:
"makes a successful attack with a firearm against a target within 30
feet")."""
def test_firearm_within_30ft_adds_d6_and_resets(self) -> None:
attacker = _combatant(
"gunner", kind="ranged", range_increment_ft=60, firearm=True
)
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_non_firearm_ranged_gets_no_bonus(self) -> None:
# A bow is a ranged attack within 30 ft but NOT a firearm: the deed
# gives no bonus and is consumed on the resolved attack.
attacker = _combatant("archer", kind="ranged", range_increment_ft=60)
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 a firearm)
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, firearm=True
)
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