diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c16c124..70dbfba 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 0b4a531..bd72771 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -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 ---