feat(combat): spellcasting default policy
This commit is contained in:
@@ -1386,17 +1386,99 @@ class CombatEngine:
|
|||||||
|
|
||||||
|
|
||||||
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
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, ...]:
|
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:
|
Decision order:
|
||||||
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
1. Heal spell if an ally is below 50% HP and a heal spell is available.
|
||||||
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
2. Offensive spell if an enemy is in range and spell slots available.
|
||||||
3. 5-foot step then full-attack if a single step brings the target into weapon range.
|
3. Buff spell if no immediate threat and spell slots available.
|
||||||
4. Move toward the target then single attack (standard + move economy).
|
4. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
||||||
5. Wait if nothing is possible.
|
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)
|
target = engine.nearest_enemy(state)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -1404,6 +1486,13 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
|
|||||||
# Must flee: withdraw from the nearest enemy
|
# Must flee: withdraw from the nearest enemy
|
||||||
if engine.has_condition_flag(state, "must_flee"):
|
if engine.has_condition_flag(state, "must_flee"):
|
||||||
return (Action(kind="withdraw", target_id=target.combatant.id),)
|
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:
|
if engine.weapon_for(state, target) is not None:
|
||||||
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
||||||
charge_weapon = engine.can_charge(state, target)
|
charge_weapon = engine.can_charge(state, target)
|
||||||
|
|||||||
Reference in New Issue
Block a user