"""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 typing import Literal from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy from pf1e_simulator.dice import parse_dice from pf1e_simulator.effects import ActiveBuff, StatModifier, resolve_modifiers 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, spell_slots: dict[str, int] | None = None, hp_max: int = 20, creature_type: Literal["living", "undead", "construct"] = "living", ) -> 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 [], spell_slots=spell_slots or {}, creature_type=creature_type, ) 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 def test_oni_channel_harm_does_not_damage_living_enemy() -> None: """Regression: positive energy must not damage living creatures (reported bug: oni harmed a Giant Belostomatid — living vermin). Harm mode only hits undead.""" oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) vermin = _combatant("belostomatid", hp_max=30) engine, me, enemy = _engine(oni, [vermin], rng_queue=[11]) action = Action( kind="channel_energy", target_id=vermin.id, channel_mode="harm" ) engine._execute(me, action) assert me.hp == 30 # living caster unaffected by harm mode assert enemy[0].hp == 30 # living vermin unaffected by harm mode def test_oni_channel_harm_damages_undead() -> None: """Harm mode damages undead creatures in the burst regardless of side; a failed Will save takes full damage (CRB Channel Energy).""" oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) lich = _combatant("lich", hp_max=100, creature_type="undead") engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 5]) action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm") engine._execute(me, action) assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11) # Will save fails: d20 5 + will 4 = 9 < DC 14 (10 + lvl//2 2 + cha 2) assert any("Will DC14" in line and "FAILED full" in line for line in engine._transcript) def test_oni_channel_harm_will_save_halves_damage() -> None: """CRB Channel Energy: an undead that passes the Will save takes half damage (floor).""" oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) lich = _combatant("lich", hp_max=100, creature_type="undead") engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 20]) action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm") engine._execute(me, action) assert enemy[0].hp == 100 - (1 + 11) // 2 # 2d6 = 12, saved for half = 6 assert any("Will DC14" in line and "SAVED half" in line for line in engine._transcript) def test_oni_channel_heal_heals_living_enemies_in_range() -> None: """Heal mode heals all living creatures in the burst — including enemies (Selective Channeling, which would exclude them, is not modeled).""" oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) goblin = _combatant("goblin", hp_max=6) engine, me, enemy = _engine(oni, [goblin], rng_queue=[5]) enemy[0].hp = 2 action = Action( kind="channel_energy", target_id=me.combatant.id, channel_mode="heal" ) engine._execute(me, action) assert enemy[0].hp == min(2 + (1 + 5), 6) # healed, capped at hp_max def test_oni_emits_channel_harm_only_for_grouped_undead() -> None: """Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft — living enemies are never harmed by positive energy.""" oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) lich1 = _combatant("lich1", hp_max=100, creature_type="undead") engine, me, _ = _engine(oni, [lich1]) assert not any(a.kind == "channel_energy" for a in default_policy(engine, me)) lich2 = _combatant("lich2", hp_max=100, creature_type="undead") engine, me, _ = _engine(oni, [lich1, lich2]) actions = default_policy(engine, me) assert any( a.kind == "channel_energy" and a.channel_mode == "harm" for a in actions ) # --- 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 def test_evil_eye_failed_save_deals_minus_two_attack_three_rounds() -> None: """APG Evil Eye: -2 on one chosen option, 3 rounds; Will does not negate. Given: a target that fails the Will save (d20 2 +4 < DC 14) When: the witch hexes it Then: a -2 attack modifier is applied for 3 rounds (no shaken condition).""" nairda = _combatant("nairda", resources={"hexes": 5}) goblin = _combatant("goblin", hp_max=6) engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2]) engine._execute( me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye") ) hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")] assert len(hex_buffs) == 1 assert hex_buffs[0].rounds_left == 3 assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),) assert not any(ac.condition.name == "shaken" for ac in enemy[0].active_conditions) def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None: """APG Evil Eye: "A Will save reduces this to just 1 round" — the penalty still lands. Given: a target that saves (natural 20) When: the witch hexes it Then: the -2 attack modifier is applied for exactly 1 round.""" nairda = _combatant("nairda", resources={"hexes": 5}) goblin = _combatant("goblin", hp_max=6) engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20]) engine._execute( me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye") ) hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")] assert len(hex_buffs) == 1 assert hex_buffs[0].rounds_left == 1 assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),) def test_slumber_failed_save_puts_target_asleep_for_witch_level() -> None: """APG Slumber: the target "remains asleep for a number of rounds equal to the witch's level". Given: a level-5 witch and a target failing the Will save (d20 2 +4 < DC 14) When: the hex is cast Then: the asleep condition carries rounds_left = 5.""" nairda = _combatant("nairda", resources={"hexes": 5}) goblin = _combatant("goblin", hp_max=6) engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2]) engine._execute( me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber") ) asleep = [ ac for ac in enemy[0].active_conditions if ac.condition.name == "asleep" ] assert len(asleep) == 1 assert asleep[0].rounds_left == 5 def test_slumber_saved_leaves_target_awake() -> None: """APG Slumber: Will negates. Given: a target saving (natural 20) When: the hex is cast Then: no asleep condition is applied.""" nairda = _combatant("nairda", resources={"hexes": 5}) goblin = _combatant("goblin", hp_max=6) engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20]) engine._execute( me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber") ) assert not any( ac.condition.name == "asleep" for ac in enemy[0].active_conditions ) # --- 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) def test_tammara_silent_when_target_out_of_deed_range() -> None: tammara = _combatant("tammara", resources={"grit": 3}) goblin = _combatant("goblin", hp_max=6) engine, me, _ = _engine(tammara, []) far_goblin = _state(goblin, "monsters", (11, 11), hp=6) engine.states.append(far_goblin) actions = default_policy(engine, me) assert not any(a.kind == "deed" for a in actions) def test_tammara_deed_out_of_range_spends_no_grit() -> None: tammara = _combatant("tammara", resources={"grit": 3}) goblin = _combatant("goblin", hp_max=6) engine, me, _ = _engine(tammara, []) far_goblin = _state(goblin, "monsters", (11, 11), hp=6) engine.states.append(far_goblin) engine._execute( me, Action( kind="deed", target_id=far_goblin.combatant.id, deed_name="up_close_and_deadly", ), ) assert me.resources_left["grit"] == 3 # --- misty: spell_combat + inspire_courage --- def test_misty_emits_inspire_courage_and_spell_combat() -> None: # Shocking Grasp (not Burning Hands): the offensive-spell scan reads # top-level damage effects, and Burning Hands is a cone area spell now. misty = _combatant( "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"] ) 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) def test_spell_combat_melee_attacks_take_minus_two() -> None: """CRB Magus Spell Combat: the full attack that accompanies the cast is made at -2 on every attack roll (vs the same swing in a normal full attack).""" misty = _combatant( "misty", resources={"bardic_rounds": 12}, spells=["Create Alcool"] ) goblin = _combatant("goblin", hp_max=6) reg = load_spell_registry(SPELLS_DIR) # Queue: goblin AoO d20 (natural 1, auto-miss), then misty's swing d20=10. engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 10]) engine._execute( me, Action( kind="spell_combat", target_id=enemy[0].combatant.id, spell_name="Create Alcool", ), ) # Sword attack_bonus 3 + Spell Combat -2 = +1: d20 10 -> total 11 (misses AC 15). assert any( "sword vs goblin d20=10+1=11" in line for line in engine._transcript ) def test_normal_full_attack_has_no_spell_combat_penalty() -> None: """Control: the same scripted swing in a plain full attack keeps its +3.""" misty = _combatant("misty", resources={"bardic_rounds": 12}) goblin = _combatant("goblin", hp_max=6) reg = load_spell_registry(SPELLS_DIR) engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[10]) engine._execute(me, Action(kind="full_attack", target_id=enemy[0].combatant.id)) assert any( "sword vs goblin d20=10+3=13" in line for line in engine._transcript ) def test_spell_combat_penalty_applies_to_spell_touch_attack() -> None: """CRB Magus Spell Combat: "any attack roll made as part of this spell also takes this penalty" — Shocking Grasp's melee touch attack is at -2 (bab 3 + str 2 - 2 = +3): d20 8 -> 11 misses touch AC 12, so the spell is wasted. Without the penalty the same roll (8+5=13) would have hit.""" misty = _combatant( "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"], spell_slots={1: 1}, ) goblin = _combatant("goblin", hp_max=6) reg = load_spell_registry(SPELLS_DIR) # Queue: goblin AoO on the cast (nat 1 miss), touch d20=8, swing d20=10. engine, me, enemy = _engine( misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 10] ) engine._execute( me, Action( kind="spell_combat", target_id=enemy[0].combatant.id, spell_name="Shocking Grasp", ), ) assert any( "touch attack vs goblin d20=8+3=11 AC 12 -> MISS" in line for line in engine._transcript ) assert enemy[0].hp == 6 # the missed touch delivers no electricity def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None: """Control: the same Shocking Grasp cast through cast_spell keeps +5 (bab 3 + str 2): d20 8 -> 13 hits touch AC 12 and deals damage.""" misty = _combatant( "misty", spells=["Shocking Grasp"], spell_slots={1: 1} ) goblin = _combatant("goblin", hp_max=60) reg = load_spell_registry(SPELLS_DIR) # Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each # (SRD spells/shocking-grasp.md: 1d6 per caster level, max 5d6; lvl 5). engine, me, enemy = _engine( misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4, 4, 4, 4, 4] ) engine._execute( me, Action(kind="cast_spell", target_id=enemy[0].combatant.id, spell_name="Shocking Grasp") ) assert any( "touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line for line in engine._transcript ) assert enemy[0].hp == 60 - 20 def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]: """Misty + one ally in 30ft, inspire courage executed on the ally.""" misty = _combatant("misty", resources={"bardic_rounds": 12}) ally = _combatant("ally") engine, me, _ = _engine(misty, [_combatant("goblin", hp_max=6)]) ally_state = _state(ally, "players", (5, 7)) engine.states.append(ally_state) engine._execute( me, Action(kind="inspire_courage", target_id=ally_state.combatant.id) ) return engine, me, ally_state def test_inspire_courage_grants_competence_attack_damage() -> None: """CRB Inspire Courage: the attack/damage bonus is COMPETENCE (not morale).""" _, _, ally = _inspired_ally() mods = { (m.target, m.bonus_type): m.value for buff in ally.active_buffs for m in buff.modifiers } assert mods[("attack", "competence")] == 1 assert mods[("damage", "competence")] == 1 def test_inspire_courage_stacks_with_heroism_morale() -> None: """CRB stacking: +1 competence (inspire courage) and +2 morale (heroism) are different bonus types — both apply to the attack roll.""" engine, _, ally = _inspired_ally() ally.active_buffs.append( ActiveBuff( modifiers=(StatModifier(target="attack", value=2, bonus_type="morale"),), rounds_left=10, source="heroism", ) ) total = resolve_modifiers(engine._stat_modifiers(ally, "attack")) assert total == 3 # competence +1 + morale +2 def test_inspire_courage_morale_bonus_on_charm_and_fear_saves() -> None: """CRB Inspire Courage: +1 morale on saves vs charm (mind_affecting) and fear; morale never stacks with itself, so both descriptors total +1.""" engine, _, ally = _inspired_ally() def will_bonus(*descriptors: str) -> int: return resolve_modifiers( engine._stat_modifiers( ally, "will", save_descriptors=frozenset(descriptors) ) ) assert will_bonus("fear") == 1 assert will_bonus("mind_affecting") == 1 assert will_bonus("fear", "mind_affecting") == 1 assert will_bonus() == 0