Files
pf1e-simulator/tests/test_class_abilities.py
T
ctan a9b33e4055 fix(rules): apply the spell combat penalty to the spell attack
PF1e (CRB Magus Spell Combat): "any attack roll made as part of this
spell also takes this penalty". _execute_spell_combat now threads the -2
through _cast_spell into the melee/ranged touch attack helpers, so e.g.
Shocking Grasp delivered via Spell Combat attacks at -2 too. No global
state is touched: the penalty is a keyword threaded per cast.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 11:14:30 +02:00

489 lines
17 KiB
Python

"""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
# --- 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:
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)
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=6)
reg = load_spell_registry(SPELLS_DIR)
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 1d6=4.
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 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 == 2
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