"""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