diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 70dbfba..92c2646 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -8857,7 +8857,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(engine, state, target) + swift_bonus = _swift_bonus_actions(engine, state, target, normal) return (*swift_bonus, *normal) if swift_bonus else normal @@ -8930,17 +8930,30 @@ def _spell_combat_actions( ) +_DEED_ATTACK_KINDS: frozenset[str] = frozenset({"attack", "full_attack"}) + + def _swift_bonus_actions( - engine: CombatEngine, state: CombatantState, target: CombatantState + engine: CombatEngine, + state: CombatantState, + target: CombatantState, + normal: tuple[Action, ...], ) -> 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 only - # when the target is within Up Close and Deadly range (30 ft). + # Deed (tammara): Up Close and Deadly spends 1 grit up front and its +1d6 + # rides the FIRST resolved attack, firearm-only within 30 ft (Ultimate + # Combat). Emitting it when the normal action resolves no such attack + # (melee swing, spell cast) burns grit for nothing, so gate on the weapon + # the turn will actually fire and on the sequence containing an attack. + weapon = engine.weapon_for(state, target) if ( state.resources_left.get("grit", 0) > 0 and engine.creature_distance(state, target) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT + and weapon is not None + and weapon.firearm + and any(a.kind in _DEED_ATTACK_KINDS for a in normal) ): bonus.append( Action( diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index bd72771..f7ea339 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -34,14 +34,27 @@ def _combatant( spell_slots: dict[str, int] | None = None, hp_max: int = 20, creature_type: Literal["living", "undead", "construct"] = "living", + firearm: bool = False, ) -> Combatant: - attack = AttackSpec( - id=f"{cid}-w", - name="sword", - kind="melee", - attack_bonus=3, - damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], - ) + if firearm: + # Tammara-style: a firearm (pistol) as her ONLY weapon. + attack = AttackSpec( + id=f"{cid}-w", + name="pistol", + kind="touch", + attack_bonus=3, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], + range_increment_ft=20, + firearm=True, + ) + else: + attack = AttackSpec( + id=f"{cid}-w", + name="sword", + kind="melee", + attack_bonus=3, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + ) return Combatant( id=cid, name=cid, @@ -373,7 +386,7 @@ def test_slumber_saved_leaves_target_awake() -> None: def test_tammara_emits_deed_alongside_attack() -> None: - tammara = _combatant("tammara", resources={"grit": 3}) + tammara = _combatant("tammara", resources={"grit": 3}, firearm=True) goblin = _combatant("goblin", hp_max=6) engine, me, _ = _engine(tammara, [goblin]) @@ -383,6 +396,39 @@ def test_tammara_emits_deed_alongside_attack() -> None: assert any(a.kind in {"full_attack", "attack"} for a in actions) +def test_tammara_no_deed_when_followup_attack_is_melee() -> None: + """Regression: Up Close and Deadly spends 1 grit at execution and its + +1d6 rides only a firearm attack within 30 ft; emitted alongside a melee + full attack the bonus is lost and the grit burned. The policy must skip + the deed when the weapon the turn will fire is not a firearm.""" + tammara = _combatant("tammara", resources={"grit": 3}) # sword only + goblin = _combatant("goblin", hp_max=6) + engine, me, _ = _engine(tammara, [goblin]) + + actions = default_policy(engine, me) + + assert not any(a.kind == "deed" for a in actions) + assert any(a.kind == "full_attack" for a in actions) + + +def test_tammara_no_deed_when_turn_casts_instead_of_attacking() -> None: + """Even with a firearm selected, a turn whose normal action is a spell + cast resolves no weapon attack: the deed flag would burn grit unused.""" + tammara = _combatant( + "tammara", resources={"grit": 3}, firearm=True, + spells=["Divine Favor"], spell_slots={1: 1}, + ) + goblin = _combatant("goblin", hp_max=6) + engine, me, _ = _engine( + tammara, [goblin], spell_registry=load_spell_registry(SPELLS_DIR) + ) + + actions = default_policy(engine, me) + + assert any(a.kind == "cast_spell" for a in actions) + assert not any(a.kind == "deed" for a in actions) + + def test_tammara_silent_when_grit_exhausted() -> None: tammara = _combatant("tammara", resources={"grit": 0}) goblin = _combatant("goblin", hp_max=6)