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:
@@ -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),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user