fix(combat): _spell_combat_actions gates on _has_slot - with exhausted slots the cast is rejected (NO SPELL SLOT) and spell_combat resolves as a bare full attack at -2, strictly worse than the plain full attack; the policy now falls through (mirrors _choose_spell_action's slot gate) + 2 regression tests

This commit is contained in:
2026-09-19 18:41:46 +02:00
parent 657f48474c
commit 31d47aee79
2 changed files with 60 additions and 2 deletions
+9 -1
View File
@@ -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),
+51 -1
View File
@@ -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"]