feat(combat): class abilities from default_policy
This commit is contained in:
+113
-10
@@ -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, ...]:
|
||||
|
||||
Reference in New Issue
Block a user