"""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)