From 930061e55a6ceb176561cbdc45e43ca19b8b35c0 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 21:27:08 +0200 Subject: [PATCH] fix(combat): deed range gating --- src/pf1e_simulator/combat.py | 43 +++++++++++++++++++++-------------- tests/test_class_abilities.py | 31 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d658856..3773305 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1568,30 +1568,35 @@ class CombatEngine: """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" if deed_name not in ("up_close_and_deadly", "quick_clear"): return - if not state.spend_resource("grit", 1): - self._log( - f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" - ) - return if deed_name == "up_close_and_deadly": # Up Close and Deadly: +1d6 damage within 30ft if target is None or not target.active: return dist_ft = self._grid.distance(state.pos, target.pos) * _SQUARE_FT - if dist_ft <= _UP_CLOSE_RANGE_FT: - # Apply +1d6 damage to next attack this round - state.deed_up_close_and_deadly = True - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" - ) - else: + if dist_ft > _UP_CLOSE_RANGE_FT: self._log( f"round {self._current_round} {state.combatant.id}: deed Up Close " f"and Deadly - target out of range" ) + return + if not state.spend_resource("grit", 1): + self._log( + f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" + ) + return + # Apply +1d6 damage to next attack this round + state.deed_up_close_and_deadly = True + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" + ) elif deed_name == "quick_clear": # Quick Clear: swift action, clears misfire + if not state.spend_resource("grit", 1): + self._log( + f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" + ) + return self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Quick Clear - firearm cleared" @@ -2534,7 +2539,7 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, return spell_combat # Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses. - swift_bonus = _swift_bonus_actions(state, target) + swift_bonus = _swift_bonus_actions(engine, state, target) return (*swift_bonus, *normal) if swift_bonus else normal @@ -2597,13 +2602,17 @@ def _spell_combat_actions( def _swift_bonus_actions( - state: CombatantState, target: CombatantState + engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...]: """Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn.""" bonus: list[Action] = [] - # Deed (tammara): swift/free action, emitted alongside the normal action. - if state.resources_left.get("grit", 0) > 0: + # Deed (tammara): swift/free action, emitted alongside the normal action only + # when the target is within Up Close and Deadly range (30 ft). + if ( + state.resources_left.get("grit", 0) > 0 + and engine.grid.distance(state.pos, target.pos) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT + ): bonus.append( Action( kind="deed", diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 6fc376d..d8f745c 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -192,6 +192,37 @@ def test_tammara_silent_when_grit_exhausted() -> None: assert not any(a.kind == "deed" for a in actions) +def test_tammara_silent_when_target_out_of_deed_range() -> None: + tammara = _combatant("tammara", resources={"grit": 3}) + goblin = _combatant("goblin", hp_max=6) + engine, me, _ = _engine(tammara, []) + far_goblin = _state(goblin, "monsters", (11, 11), hp=6) + engine.states.append(far_goblin) + + actions = default_policy(engine, me) + + assert not any(a.kind == "deed" for a in actions) + + +def test_tammara_deed_out_of_range_spends_no_grit() -> None: + tammara = _combatant("tammara", resources={"grit": 3}) + goblin = _combatant("goblin", hp_max=6) + engine, me, _ = _engine(tammara, []) + far_goblin = _state(goblin, "monsters", (11, 11), hp=6) + engine.states.append(far_goblin) + + engine._execute( + me, + Action( + kind="deed", + target_id=far_goblin.combatant.id, + deed_name="up_close_and_deadly", + ), + ) + + assert me.resources_left["grit"] == 3 + + # --- misty: spell_combat + inspire_courage ---