fix(combat): _choose_spell_action heal branch gates on spell range + line of effect - a touch-range Cure on a distant ally was rejected by _cast_spell (OUT OF RANGE / NO LINE OF EFFECT) and wasted the caster's whole turn; extracted the offensive branch's reach check as _spell_reaches and reused it (the duplicated below-half check in the branch was a no-op copy of _find_ally_needing_heal, dropped) + 3 policy tests

This commit is contained in:
2026-09-19 18:48:58 +02:00
parent 31d47aee79
commit bdf15aa8fe
2 changed files with 115 additions and 8 deletions
+25 -7
View File
@@ -8319,11 +8319,27 @@ def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> Com
return None return None
def _spell_reaches(
engine: CombatEngine,
caster: CombatantState,
target: CombatantState,
spell: SpellSpec,
) -> bool:
"""True if the spell's range covers caster -> target with line of effect
(the same gate `_cast_spell` applies; a proposed cast failing it wastes
the whole turn on an OUT OF RANGE / NO LINE OF EFFECT rejection)."""
dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT
return dist_ft <= spell_range_ft(spell, caster.combatant.level) and (
has_line_of_effect_cells(
engine.grid, caster.occupied_cells(), target.occupied_cells()
)
)
def _find_offensive_spell( def _find_offensive_spell(
engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState
) -> str | None: ) -> str | None:
"""Find an offensive spell that can reach the target.""" """Find an offensive spell that can reach the target."""
dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT
for name in spells_known: for name in spells_known:
spell = engine.spell_registry.get(name) spell = engine.spell_registry.get(name)
if not spell: if not spell:
@@ -8332,10 +8348,7 @@ def _find_offensive_spell(
has_damage = any(isinstance(e, DamageEffect) for e in spell.effects) has_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
if not has_damage: if not has_damage:
continue continue
max_range = spell_range_ft(spell, caster.combatant.level) if _spell_reaches(engine, caster, target, spell):
if dist_ft <= max_range and has_line_of_effect_cells(
engine.grid, caster.occupied_cells(), target.occupied_cells()
):
return name return name
return None return None
@@ -8375,7 +8388,9 @@ def _choose_spell_action(
if not spells_known: if not spells_known:
return None return None
# 1. Heal spell if ally below 50% HP # 1. Heal spell if ally below 50% HP (within range and line of effect:
# a touch-range Cure on a distant ally is rejected by _cast_spell and
# would waste the whole turn).
heal_spell = _find_heal_spell(engine, spells_known) heal_spell = _find_heal_spell(engine, spells_known)
if ( if (
heal_spell heal_spell
@@ -8383,7 +8398,10 @@ def _choose_spell_action(
and _has_slot(engine, caster, heal_spell) and _has_slot(engine, caster, heal_spell)
): ):
ally = _find_ally_needing_heal(engine, caster) ally = _find_ally_needing_heal(engine, caster)
if ally and ally.hp < ally.combatant.hp_max // 2: spell = engine.spell_registry.get(heal_spell)
if ally is not None and spell is not None and _spell_reaches(
engine, caster, ally, spell
):
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),) return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
# 2. Offensive spell if enemy in range # 2. Offensive spell if enemy in range
+90 -1
View File
@@ -23,7 +23,7 @@ from pf1e_simulator.models import (
Saves, Saves,
) )
from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, load_spell_registry from pf1e_simulator.spells import SPELLS_DIR, HealEffect, SpellSpec, load_spell_registry
def _combatant( def _combatant(
@@ -424,6 +424,95 @@ def test_tammara_deed_out_of_range_spends_no_grit() -> None:
assert me.resources_left["grit"] == 3 assert me.resources_left["grit"] == 3
# --- policy heal branch: range gate ---
def _healer(hp_max: int = 20) -> Combatant:
return _combatant(
"cleric", spells=["Cure Light Wounds"], spell_slots={1: 1}, hp_max=hp_max
)
def test_heal_cast_when_wounded_ally_in_touch_range() -> None:
"""A touch-range Cure is proposed only when the ally is within the spell's
range: ally at 5 ft (1 cell) -> cast_spell on the ally."""
cleric = _healer()
engine, me, _ = _engine(
cleric, [_combatant("goblin", hp_max=6)],
spell_registry=load_spell_registry(SPELLS_DIR),
)
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 4), hp=5)
engine.states.append(hurt_ally)
actions = default_policy(engine, me)
assert actions[0].kind == "cast_spell"
assert actions[0].target_id == "ally"
assert actions[0].spell_name == "Cure Light Wounds"
def test_no_heal_cast_when_wounded_ally_out_of_range() -> None:
"""Regression: a touch-range Cure on an ally 20 ft away is rejected by
_cast_spell (OUT OF RANGE), wasting the caster's whole turn. The policy
must skip the cast and fall through to the attack path."""
cleric = _healer()
engine, me, _ = _engine(
cleric, [_combatant("goblin", hp_max=6)],
spell_registry=load_spell_registry(SPELLS_DIR),
)
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 9), hp=5)
engine.states.append(hurt_ally)
actions = default_policy(engine, me)
assert not any(a.kind == "cast_spell" for a in actions)
assert any(a.kind == "full_attack" for a in actions)
def test_no_heal_cast_when_wounded_ally_blocked_by_wall() -> None:
"""Line-of-effect gate (distinct from range): a close-range Cure reaches
the ally by distance (15 ft < 35 ft at level 5) but a solid barrier
cancels line of effect, so the cast would be rejected NO LINE OF EFFECT
and the policy must fall through."""
cleric = _combatant(
"cleric", spells=["Wall Blocked Cure"], spell_slots={1: 1}, hp_max=20
)
cure = SpellSpec(
name="Wall Blocked Cure", level=1, school="conjuration", range="close",
effects=(HealEffect(formula=parse_dice("1d8")),),
)
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
rows = (
"............",
"............",
"............",
"............",
"............",
".....#.#####",
"............",
"............",
"............",
"............",
"............",
"............",
)
spec = MapSpec(name="walled", terrain=rows, legend=legend)
me = _state(cleric, "players", (5, 5))
enemy = _state(_combatant("goblin", hp_max=6), "monsters", (6, 6))
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 8), hp=5)
engine = CombatEngine(
ScriptedRng([]), Grid.from_spec(spec), [me, enemy, hurt_ally],
spell_registry={"Wall Blocked Cure": cure},
)
actions = default_policy(engine, me)
assert not any(a.kind == "cast_spell" for a in actions)
# --- misty: spell_combat + inspire_courage --- # --- misty: spell_combat + inspire_courage ---