feat(combat): class abilities from default_policy

This commit is contained in:
2026-09-04 22:56:02 +02:00
parent 66a664ad85
commit 6e08df6eb6
2 changed files with 336 additions and 10 deletions
+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)