diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 58a6d32..c16c124 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -8892,12 +8892,20 @@ def _emit_hex( def _spell_combat_actions( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...] | None: - """Misty's spell_combat (main) + inspire_courage (swift), or None if no offensive spell.""" + """Misty's spell_combat (main) + inspire_courage (swift), None if not castable. + + Slot gate (mirror of ``_choose_spell_action``): with no slot left for the + chosen spell the cast is rejected (``NO SPELL SLOT``), so the executor + would run the action as a bare full attack at -2 - strictly worse than the + plain full attack the policy would otherwise choose. Fall through instead. + """ if state.resources_left.get("bardic_rounds", 0) <= 0: return None offensive = _find_offensive_spell(engine, state.combatant.spells, state, target) if offensive is None: return None + if not _has_slot(engine, state, offensive): + return None return ( Action(kind="inspire_courage", target_id=target.combatant.id), Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive), diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 2174984..0b4a531 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -431,7 +431,8 @@ def test_misty_emits_inspire_courage_and_spell_combat() -> None: # Shocking Grasp (not Burning Hands): the offensive-spell scan reads # top-level damage effects, and Burning Hands is a cone area spell now. misty = _combatant( - "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"] + "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"], + spell_slots={1: 1}, ) goblin = _combatant("goblin", hp_max=6) reg = load_spell_registry(SPELLS_DIR) @@ -443,6 +444,55 @@ def test_misty_emits_inspire_courage_and_spell_combat() -> None: assert any(a.kind == "spell_combat" for a in actions) +def test_misty_no_spell_combat_when_slots_exhausted() -> None: + """Slot gate: with no slot left the cast would be rejected (NO SPELL SLOT) + and spell_combat would resolve as a bare full attack at -2 - strictly worse + than the plain full attack. The policy must fall through to full_attack.""" + misty = _combatant( + "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"], + spell_slots={1: 0}, + ) + goblin = _combatant("goblin", hp_max=6) + reg = load_spell_registry(SPELLS_DIR) + engine, me, _ = _engine(misty, [goblin], spell_registry=reg) + + actions = default_policy(engine, me) + + assert not any(a.kind == "spell_combat" for a in actions) + assert any(a.kind == "full_attack" for a in actions) + + +def test_spell_combat_without_slot_costs_no_minus_two_in_execution() -> None: + """Executor-side evidence for the policy gate: a spell_combat with no slot + leaves the caster without the -2 spell AND with the full attack at -2 + (d20 10 + bonus 3 - 2 = 11, misses AC 15) - the wasted-turn trap the + policy must avoid proposing.""" + misty = _combatant( + "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"], + spell_slots={1: 0}, + ) + goblin = _combatant("goblin", hp_max=6) + reg = load_spell_registry(SPELLS_DIR) + # Queue: the rejected cast consumes no die (the slot gate precedes the + # casting AoO), so the first roll is misty's swing d20=10. + engine, me, enemy = _engine( + misty, [goblin], spell_registry=reg, rng_queue=[10] + ) + + engine._execute( + me, + Action( + kind="spell_combat", target_id=enemy[0].combatant.id, + spell_name="Shocking Grasp", + ), + ) + + assert any("NO SPELL SLOT" in line for line in engine._transcript) + assert any( + "sword vs goblin d20=10+1=11" in line for line in engine._transcript + ) + + def test_misty_silent_when_bardic_rounds_exhausted() -> None: misty = _combatant( "misty", resources={"bardic_rounds": 0}, spells=["Burning Hands"]