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.
|
"""Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range.
|
||||||
|
|
||||||
Decision order:
|
Decision order:
|
||||||
1. Heal spell if an ally is below 50% HP and a heal spell is available.
|
1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped.
|
||||||
2. Offensive spell if an enemy is in range and spell slots available.
|
2. Hex (nairda) if resource available and the target is within close range.
|
||||||
3. Buff spell if no immediate threat and spell slots available.
|
3. Heal spell if an ally is below 50% HP and a heal spell is available.
|
||||||
4. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
4. Offensive spell if an enemy is in range and spell slots available.
|
||||||
5. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
5. Buff spell if no immediate threat and spell slots available.
|
||||||
5. 5-foot step then full-attack if a single step brings the target into weapon range.
|
6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
||||||
6. Move toward the target then single attack (standard + move economy).
|
7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
||||||
7. Wait if nothing is possible.
|
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).
|
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"):
|
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
|
# 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:
|
if state.combatant.spells:
|
||||||
spell_action = _choose_spell_action(engine, state, target)
|
spell_action = _choose_spell_action(engine, state, target)
|
||||||
if spell_action is not None:
|
if spell_action is not None:
|
||||||
return spell_action
|
return spell_action
|
||||||
|
|
||||||
return _melee_action(engine, state, target)
|
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(
|
def _melee_action(
|
||||||
engine: CombatEngine, state: CombatantState, target: CombatantState
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
||||||
) -> tuple[Action, ...]:
|
) -> tuple[Action, ...]:
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for class abilities emitted by default_policy, gated on resources.
|
||||||
|
|
||||||
|
Covers the 4 PC resource pools: oni (channel_energy), nairda (hex),
|
||||||
|
tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
|
||||||
|
"emit when available" and a "silent when exhausted" test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, load_spell_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
resources: dict[str, int] | None = None,
|
||||||
|
spells: list[str] | None = None,
|
||||||
|
hp_max: int = 20,
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=3,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=5,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=14, dex_score=14, con_score=12,
|
||||||
|
int_score=14, wis_score=12, cha_score=14,
|
||||||
|
),
|
||||||
|
hp_max=hp_max,
|
||||||
|
ac=ACProfile(total=15, touch=12, flat_footed=14),
|
||||||
|
bab=3,
|
||||||
|
initiative_mod=2,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=4, ref=3, will=4),
|
||||||
|
attacks=[attack],
|
||||||
|
resources=resources or {},
|
||||||
|
spells=spells or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(
|
||||||
|
combatant: Combatant, side: str, pos: tuple[int, int], hp: int | None = None
|
||||||
|
) -> CombatantState:
|
||||||
|
state = CombatantState(
|
||||||
|
combatant=combatant, side=side, pos=pos, hp=hp if hp is not None else combatant.hp_max
|
||||||
|
)
|
||||||
|
state.resources_left = dict(combatant.resources)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _grid() -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
combatant: Combatant,
|
||||||
|
enemies: list[Combatant],
|
||||||
|
*,
|
||||||
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
|
rng_queue: list[int] | None = None,
|
||||||
|
) -> tuple[CombatEngine, CombatantState, list[CombatantState]]:
|
||||||
|
me = _state(combatant, "players", (5, 5))
|
||||||
|
enemy_states = [
|
||||||
|
_state(e, "monsters", (5, 5 + i + 1)) for i, e in enumerate(enemies)
|
||||||
|
]
|
||||||
|
states = [me, *enemy_states]
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng(rng_queue or []), _grid(), states, spell_registry=spell_registry
|
||||||
|
)
|
||||||
|
return engine, me, enemy_states
|
||||||
|
|
||||||
|
|
||||||
|
# --- oni: channel_energy ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_oni_emits_channel_energy_when_ally_hurt() -> None:
|
||||||
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(oni, [goblin])
|
||||||
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
||||||
|
engine.states.append(hurt_ally)
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert any(a.kind == "channel_energy" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oni_silent_when_channel_energy_exhausted() -> None:
|
||||||
|
oni = _combatant("oni", resources={"channel_energy": 0}, hp_max=30)
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(oni, [goblin])
|
||||||
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
||||||
|
engine.states.append(hurt_ally)
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert not any(a.kind == "channel_energy" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oni_channel_energy_spends_resource() -> None:
|
||||||
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(oni, [goblin], rng_queue=[5])
|
||||||
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
||||||
|
engine.states.append(hurt_ally)
|
||||||
|
|
||||||
|
action = Action(
|
||||||
|
kind="channel_energy", target_id=engine.nearest_enemy(me).combatant.id
|
||||||
|
)
|
||||||
|
engine._execute(me, action)
|
||||||
|
|
||||||
|
assert me.resources_left["channel_energy"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
# --- nairda: hex ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_nairda_emits_hex_when_available() -> None:
|
||||||
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(nairda, [goblin])
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert any(a.kind == "hex" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nairda_silent_when_hexes_exhausted() -> None:
|
||||||
|
nairda = _combatant("nairda", resources={"hexes": 0})
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(nairda, [goblin])
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert not any(a.kind == "hex" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nairda_hex_spends_resource() -> None:
|
||||||
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
||||||
|
|
||||||
|
action = Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
||||||
|
engine._execute(me, action)
|
||||||
|
|
||||||
|
assert me.resources_left["hexes"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# --- tammara: deeds ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_tammara_emits_deed_alongside_attack() -> None:
|
||||||
|
tammara = _combatant("tammara", resources={"grit": 3})
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(tammara, [goblin])
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert any(a.kind == "deed" for a in actions)
|
||||||
|
assert any(a.kind in {"full_attack", "attack"} for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tammara_silent_when_grit_exhausted() -> None:
|
||||||
|
tammara = _combatant("tammara", resources={"grit": 0})
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
engine, me, _ = _engine(tammara, [goblin])
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert not any(a.kind == "deed" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
# --- misty: spell_combat + inspire_courage ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
|
||||||
|
misty = _combatant(
|
||||||
|
"misty", resources={"bardic_rounds": 12}, spells=["Burning Hands"]
|
||||||
|
)
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
reg = load_spell_registry(SPELLS_DIR)
|
||||||
|
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert any(a.kind == "inspire_courage" for a in actions)
|
||||||
|
assert any(a.kind == "spell_combat" for a in actions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_misty_silent_when_bardic_rounds_exhausted() -> None:
|
||||||
|
misty = _combatant(
|
||||||
|
"misty", resources={"bardic_rounds": 0}, spells=["Burning Hands"]
|
||||||
|
)
|
||||||
|
goblin = _combatant("goblin", hp_max=6)
|
||||||
|
reg = load_spell_registry(SPELLS_DIR)
|
||||||
|
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
|
||||||
|
|
||||||
|
actions = default_policy(engine, me)
|
||||||
|
|
||||||
|
assert not any(a.kind == "inspire_courage" for a in actions)
|
||||||
|
assert not any(a.kind == "spell_combat" for a in actions)
|
||||||
Reference in New Issue
Block a user