feat(combat): class abilities from default_policy

This commit is contained in:
ctan
2026-09-01 16:38:52 +02:00
parent 0de5452a39
commit 83d37e0f8b
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, ...]:
+223
View File
@@ -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)