feat(combat): class abilities from default_policy

This commit is contained in:
2026-09-04 22:56:02 +02:00
parent 9f9bd27ca8
commit f155146225
2 changed files with 336 additions and 10 deletions
+113 -10
View File
@@ -2490,14 +2490,25 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
"""Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range.
Decision order:
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.
1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped.
2. Hex (nairda) if resource available and the target is within close range.
3. Heal spell if an ally is below 50% HP and a heal spell is available.
4. Offensive spell if an enemy is in range and spell slots available.
5. Buff spell if no immediate threat and spell slots available.
6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
8. 5-foot step then full-attack if a single step brings the target into weapon range.
9. Move toward the target then single attack (standard + move economy).
10. Wait if nothing is possible.
Class-ability bonus actions (deed for tammara, inspire_courage for misty) are
prepended to the normal action sequence whenever their resource is available.
Precedence for replacement (standard) abilities: channel_energy and hex are
evaluated first and replace the whole turn when their resource is available
and their condition is met; otherwise the normal spell/melee flow runs and the
swift/free abilities are combined with it. This keeps each class able to act
each turn while reserving standard-action class abilities for their triggers.
Never casts a spell if threatened without Concentration check (handled in _cast_spell).
"""
@@ -2508,15 +2519,107 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
if engine.has_condition_flag(state, "must_flee"):
return (Action(kind="withdraw", target_id=target.combatant.id),)
# Try spellcasting if caster has spells
# Channel energy (oni): standard action that heal/harms in a 30ft burst.
if _emit_channel_energy(engine, state):
return (Action(kind="channel_energy", target_id=target.combatant.id),)
# Hex (nairda): standard action when the target is within close range.
if _emit_hex(engine, state, target):
return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),)
normal = _normal_actions(engine, state, target)
# Misty spell_combat (main) + inspire_courage (swift): replaces the normal action.
spell_combat = _spell_combat_actions(engine, state, target)
if spell_combat is not None:
return spell_combat
# Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses.
swift_bonus = _swift_bonus_actions(state, target)
return (*swift_bonus, *normal) if swift_bonus else normal
def _normal_actions(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...]:
"""The spell/melee action sequence (without swift/free class-ability add-ons)."""
if state.combatant.spells:
spell_action = _choose_spell_action(engine, state, target)
if spell_action is not None:
return spell_action
return _melee_action(engine, state, target)
_GROUPED_ENEMY_COUNT = 2
_HEX_RANGE_FT = 25
def _emit_channel_energy(engine: CombatEngine, state: CombatantState) -> bool:
"""True when oni should channel energy: resource available and a trigger is met."""
if state.resources_left.get("channel_energy", 0) <= 0:
return False
if _find_ally_needing_heal(engine, state) is not None:
return True
radius_cells = 30 // _SQUARE_FT
grouped_enemies = [
s
for s in engine.states
if s.side != state.side
and s.active
and engine.grid.distance(state.pos, s.pos) <= radius_cells
]
return len(grouped_enemies) >= _GROUPED_ENEMY_COUNT
def _emit_hex(
engine: CombatEngine, state: CombatantState, target: CombatantState | None
) -> bool:
"""True when nairda should hex: resource available and target within close range."""
if state.resources_left.get("hexes", 0) <= 0:
return False
if target is None or not target.active:
return False
return engine.grid.distance(state.pos, target.pos) * _SQUARE_FT <= _HEX_RANGE_FT
def _spell_combat_actions(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...] | None:
"""Misty's spell_combat (main) + inspire_courage (swift), or None if no offensive spell."""
if state.resources_left.get("bardic_rounds", 0) <= 0:
return None
offensive = _find_offensive_spell(engine, state.combatant.spells, state, target)
if offensive is None:
return None
return (
Action(kind="inspire_courage", target_id=target.combatant.id),
Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive),
)
def _swift_bonus_actions(
state: CombatantState, target: CombatantState
) -> tuple[Action, ...]:
"""Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn."""
bonus: list[Action] = []
# Deed (tammara): swift/free action, emitted alongside the normal action.
if state.resources_left.get("grit", 0) > 0:
bonus.append(
Action(
kind="deed",
target_id=target.combatant.id,
deed_name="up_close_and_deadly",
)
)
# Inspire courage (misty melee fallback): combined with the normal action.
if state.resources_left.get("bardic_rounds", 0) > 0:
bonus.append(Action(kind="inspire_courage", target_id=target.combatant.id))
return tuple(bonus)
def _melee_action(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...]: