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:
@@ -8319,11 +8319,27 @@ def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> Com
|
||||
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(
|
||||
engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState
|
||||
) -> str | None:
|
||||
"""Find an offensive spell that can reach the target."""
|
||||
dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT
|
||||
for name in spells_known:
|
||||
spell = engine.spell_registry.get(name)
|
||||
if not spell:
|
||||
@@ -8332,10 +8348,7 @@ def _find_offensive_spell(
|
||||
has_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
||||
if not has_damage:
|
||||
continue
|
||||
max_range = spell_range_ft(spell, caster.combatant.level)
|
||||
if dist_ft <= max_range and has_line_of_effect_cells(
|
||||
engine.grid, caster.occupied_cells(), target.occupied_cells()
|
||||
):
|
||||
if _spell_reaches(engine, caster, target, spell):
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -8375,7 +8388,9 @@ def _choose_spell_action(
|
||||
if not spells_known:
|
||||
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)
|
||||
if (
|
||||
heal_spell
|
||||
@@ -8383,7 +8398,10 @@ def _choose_spell_action(
|
||||
and _has_slot(engine, caster, heal_spell)
|
||||
):
|
||||
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),)
|
||||
|
||||
# 2. Offensive spell if enemy in range
|
||||
|
||||
@@ -23,7 +23,7 @@ from pf1e_simulator.models import (
|
||||
Saves,
|
||||
)
|
||||
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(
|
||||
@@ -424,6 +424,95 @@ def test_tammara_deed_out_of_range_spends_no_grit() -> None:
|
||||
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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user