From 09062b37ea4069ebbcecae572c51d67d72dd9c4e Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:42 +0200 Subject: [PATCH] feat(combat): spellcasting default policy --- src/pf1e_simulator/combat.py | 101 ++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c49d42a..887caa9 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1386,17 +1386,99 @@ class CombatEngine: Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] +def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: + """Find a heal spell in known spells.""" + for name in spells_known: + spell = engine._spell_registry.get(name) + if spell and any(isinstance(e, HealEffect) for e in spell.effects): + return name + return None + + +def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> CombatantState | None: + """Find an ally below 50% HP.""" + for ally_state in engine._states: + if ally_state.side == caster.side and ally_state.active and ally_state.hp < ally_state.combatant.hp_max // 2: + return ally_state + return None + + +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._grid.distance(caster.pos, target.pos) * _SQUARE_FT + for name in spells_known: + spell = engine._spell_registry.get(name) + if not spell: + continue + # Check for damage effect + 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(engine._grid, caster.pos, target.pos): + return name + return None + + +def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: + """Find a buff spell.""" + for name in spells_known: + spell = engine._spell_registry.get(name) + if spell and any(isinstance(e, BuffEffect) for e in spell.effects): + return name + return None + + +def _choose_spell_action( + engine: CombatEngine, state: CombatantState, target: CombatantState +) -> tuple[Action, ...] | None: + """Select and return a spell action if appropriate, else None.""" + caster = state + spells_known = caster.combatant.spells + if not spells_known: + return None + + # Check spell slots available + slots_left = caster.resources_left.get("spell_slots", 0) + if slots_left <= 0: + return None + + # 1. Heal spell if ally below 50% HP + heal_spell = _find_heal_spell(engine, spells_known) + if heal_spell: + ally = _find_ally_needing_heal(engine, caster) + if ally and ally.hp < ally.combatant.hp_max // 2: + return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),) + + # 2. Offensive spell if enemy in range + offensive_spell = _find_offensive_spell(engine, spells_known, caster, target) + if offensive_spell: + return (Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),) + + # 3. Buff spell + buff_spell = _find_buff_spell(engine, spells_known) + if buff_spell: + return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),) + + return None def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range. + """Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range. Decision order: - 1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). - 2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). - 3. 5-foot step then full-attack if a single step brings the target into weapon range. - 4. Move toward the target then single attack (standard + move economy). - 5. Wait if nothing is possible. + 1. Heal spell if an ally is below 50% HP and a heal spell is available. + 2. Offensive spell if an enemy is in range and spell slots available. + 3. Buff spell if no immediate threat and spell slots available. + 4. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). + 5. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). + 5. 5-foot step then full-attack if a single step brings the target into weapon range. + 6. Move toward the target then single attack (standard + move economy). + 7. Wait if nothing is possible. + + Never casts a spell if threatened without Concentration check (handled in _cast_spell). """ target = engine.nearest_enemy(state) if target is None: @@ -1404,6 +1486,13 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, # Must flee: withdraw from the nearest enemy if engine.has_condition_flag(state, "must_flee"): return (Action(kind="withdraw", target_id=target.combatant.id),) + + # Try spellcasting if caster has spells + if state.combatant.spells: + spell_action = _choose_spell_action(engine, state, target) + if spell_action is not None: + return spell_action + if engine.weapon_for(state, target) is not None: return (Action(kind="full_attack", target_id=target.combatant.id),) charge_weapon = engine.can_charge(state, target)