fix(combat): _swift_bonus_actions emits up_close_and_deadly only when the turn will actually fire a firearm attack within 30 ft - the deed burns 1 grit at execution and its +1d6 rides only a firearm attack, so emitting it alongside a melee swing or a spell cast wasted grit for a lost bonus; + 2 policy tests, deed test now uses a firearm-only tammara

This commit is contained in:
2026-09-19 18:55:17 +02:00
parent e8b7a73d1f
commit f93c55b3fb
2 changed files with 71 additions and 12 deletions
+17 -4
View File
@@ -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(
+47 -1
View File
@@ -34,7 +34,20 @@ def _combatant(
spell_slots: dict[str, int] | None = None,
hp_max: int = 20,
creature_type: Literal["living", "undead", "construct"] = "living",
firearm: bool = False,
) -> Combatant:
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",
@@ -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)