feat(combat): spellcasting default policy

This commit is contained in:
ctan
2026-08-29 23:57:13 +02:00
parent 99686158cf
commit de6c605808
+95 -6
View File
@@ -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)