Files
pf1e-simulator/tests/test_conformance_spellflow.py
T
ctan 6f784d3369 fix(rules): require a ranged touch attack for ray spells
PF1e CRB (Magic - Ranged Touch Attacks): spells delivered by ray require a
ranged touch attack (BAB + Dex vs touch AC, no range increment) to hit a
hostile target; a miss wastes the slot. SpellSpec gains requires_ranged_touch,
set on the 8 SRD-confirmed rays (Ray of Frost, Ray of Enfeeblement, Scorching
Ray, Acid Arrow, Acid Splash, Disrupt Undead, Shard of Chaos, Spear of Purity).
Magic Missile and area spells are unaffected. Simplification (documented): no
size modifier / Weapon Finesse on the roll.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 20:19:03 +02:00

548 lines
22 KiB
Python

"""PF1e conformance: spell-casting flow fixes B7 / B14 / B24.
B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
(Empower +2, Maximize +3, Extend +1), the DC uses the slot level, and
only feats the caster knows can apply.
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
creature requires a melee touch attack (miss = spell wasted); a willing
target (self/ally) needs no roll.
Ranged touch (CRB, Magic, Ranged Touch Attacks): a spell with
requires_ranged_touch (a ray) must land a ranged touch attack (BAB + Dex
vs touch AC, no range increment) on a hostile target; auto-hit spells
like Magic Missile need no roll.
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
a DC 15 + 2 x spell level check; casting while grappled requires
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
both, never to the injured-while-casting check.
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import Action, ActionResult, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
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,
ConditionEffect,
DamageEffect,
SpellSpec,
load_spell,
)
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
# touch condition grace — enough to exercise slot levels, DCs and delivery.
_BOLT = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="medium",
save="none",
effects=(DamageEffect(formula=parse_dice("2d4"), types=("force",)),),
)
_ZAP = SpellSpec(
name="Zap",
level=1,
school="evocation",
range="touch",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
)
_GRACE = SpellSpec(
name="Grace",
level=1,
school="abjuration",
range="touch",
save="none",
effects=(
ConditionEffect(
condition_name="dazzled", duration_rounds=2, save_negates=False
),
),
)
_RAY = SpellSpec(
name="Ray",
level=1,
school="evocation",
range="close",
save="none",
requires_ranged_touch=True,
effects=(DamageEffect(formula=parse_dice("1d6"), types=("cold",)),),
)
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE, "Ray": _RAY}
def _caster(
cid: str = "caster",
*,
level: int = 5,
bab: int = 1,
casting_ability: str = "int",
int_score: int = 18,
wis_score: int = 10,
metamagic: list[str] | None = None,
spell_slots: dict[int, int] | None = None,
combat_casting: bool = False,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=12,
int_score=int_score, wis_score=wis_score, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=3, will=3),
attacks=[attack],
casting_ability=casting_ability,
spell_slots=spell_slots if spell_slots is not None else {1: 1, 2: 1, 3: 1, 4: 1},
metamagic_feats=metamagic or [],
features=features,
)
def _foe(
cid: str = "foe",
*,
touch_ac: int = 10,
dex_score: int = 10,
cmb: int | None = None,
melee: bool = True,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club" if melee else "short bow",
kind="melee" if melee else "ranged",
attack_bonus=0,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
range_increment_ft=None if melee else 60,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=dex_score, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=100,
ac=ACProfile(total=12, touch=touch_ac, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
cmb=cmb,
)
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(states: list[CombatantState], queue: list[int]) -> CombatEngine:
return CombatEngine(
ScriptedRng(queue), _grid(), states, spell_registry=_REGISTRY
)
def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
class TestMetamagicPerCast:
"""B7: metamagic is chosen per cast, costs slot levels, and the DC uses
the slot level (CRB, Metamagic Feats)."""
def test_empower_costs_slot_two_levels_higher_and_dc_uses_it(self) -> None:
# Given: a caster who knows Empower Spell with a level-3 slot
# When: casting Bolt (lvl 1) with Empower Spell
# Then: the level-3 slot burns and the DC is 10 + 3 + int 4 = 17.
caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3]) # 2d4 -> 6, empowered -> 9
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert c.spell_slots_left[3] == 0
assert c.spell_slots_left[1] == 1
assert f.hp == 100 - 9
assert any("DC17" in line for line in engine._transcript)
def test_maximize_costs_slot_three_levels_higher(self) -> None:
# Given: a caster who knows Maximize Spell
# When: casting Bolt maximized (2d4 max = 8)
# Then: the level-4 slot burns and the DC is 10 + 4 + 4 = 18.
caster = _caster(metamagic=["Maximize Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [1, 1]) # dice overwritten by maximize
engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",))
assert c.spell_slots_left[4] == 0
assert f.hp == 100 - 8
assert any("DC18" in line for line in engine._transcript)
def test_extend_costs_one_slot_level_and_doubles_duration(self) -> None:
# Given: a caster who knows Extend Spell, self-targeting a touch buff
# When: casting Grace (2r) extended
# Then: the level-2 slot burns and the condition lasts 4 rounds.
caster = _caster(metamagic=["Extend Spell"])
c = _state(caster, "players", (0, 0))
engine = _engine([c], []) # self touch: no attack roll, no save
engine._cast_spell(c, c, "Grace", metamagic=("Extend Spell",))
assert c.spell_slots_left[2] == 0
assert c.spell_slots_left[1] == 1
assert c.active_conditions[0].rounds_left == 4
def test_unknown_metamagic_feat_is_never_applied(self) -> None:
# Given: a caster who knows NO metamagic
# When: the action requests Empower Spell anyway
# Then: the feat is ignored — base slot, base DC, base damage.
caster = _caster(metamagic=[])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert f.hp == 100 - 6
assert any("DC15" in line for line in engine._transcript)
def test_known_but_unrequested_feat_is_not_applied(self) -> None:
# Given: a caster who knows Empower AND Maximize
# When: casting Bolt with no metamagic requested
# Then: the base slot burns, base DC, dice are rolled (not maxed).
caster = _caster(metamagic=["Empower Spell", "Maximize Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
engine._cast_spell(c, f, "Bolt")
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert c.spell_slots_left[4] == 1
assert f.hp == 100 - 6
def test_cast_rejected_without_a_slot_at_the_used_level(self) -> None:
# Given: a caster with only a level-1 slot who knows Empower
# When: an empowered cast needs a level-3 slot
# Then: the cast is rejected (no_slot), nothing is spent or rolled.
caster = _caster(metamagic=["Empower Spell"], spell_slots={1: 1})
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [])
result = engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert isinstance(result, ActionResult)
assert result.ok is False
assert result.reason == "no_slot"
assert c.spell_slots_left[1] == 1
assert f.hp == 100
assert any("NO SPELL SLOT (level 3)" in line for line in engine._transcript)
def test_action_metamagic_field_threads_through_execution(self) -> None:
# Given: a caster with slots who knows Empower
# When: the cast is issued as an Action carrying metamagic
# Then: _execute dispatches the empowered cast (level-3 slot burns).
caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
metamagic=("Empower Spell",),
),
)
assert result.ok is True
assert c.spell_slots_left[3] == 0
assert f.hp == 100 - 9
class TestHostileTouchDelivery:
"""B14: a touch spell delivered to an unwilling creature requires a
melee touch attack (CRB, Magic — Range: Touch)."""
def test_missed_touch_attack_wastes_the_spell(self) -> None:
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10)
# When: Zap is cast with a touch attack roll of 5 (total 6)
# Then: the spell misses, deals no damage, and the slot is spent.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
engine._cast_spell(c, f, "Zap")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript)
def test_successful_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy
# When: the touch attack roll is 10 (total 11 >= 10)
# Then: the zap lands for its 1d6.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [10, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 4
assert any("HIT" in line for line in engine._transcript)
def test_touch_attack_strips_dex_when_target_denies_dex(self) -> None:
# Given: a flat-footed enemy (Dex 14, touch 13 -> effective AC 11)
# When: the caster (bonus +1) rolls a 9 (total 10)
# Then: the attack misses the reduced touch AC.
caster = _caster()
foe = _state(_foe(touch_ac=13, dex_score=14, melee=False), "monsters", (0, 1))
foe.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=1,
source="test",
)
)
c = _state(caster, "players", (0, 0))
engine = _engine([c, foe], [9])
engine._cast_spell(c, foe, "Zap")
assert foe.hp == 100
assert any("AC 11" in line for line in engine._transcript)
def test_touch_spell_on_ally_needs_no_attack_roll(self) -> None:
# Given: an ally on the caster's side
# When: a touch spell is cast on them with an EMPTY die queue
# Then: no attack roll is consumed and the effect lands.
caster = _caster()
ally = _state(_foe(), "players", (0, 1))
c = _state(caster, "players", (0, 0))
engine = _engine([c, ally], [])
engine._cast_spell(c, ally, "Grace")
assert ally.active_conditions[0].condition.name == "dazzled"
def test_self_touch_spell_needs_no_attack_roll(self) -> None:
# Given: a caster targeting self
# When: a touch spell is cast with an EMPTY die queue
# Then: it lands without any attack roll (unchanged behaviour).
c = _state(_caster(), "players", (0, 0))
engine = _engine([c], [])
engine._cast_spell(c, c, "Grace")
assert c.active_conditions[0].condition.name == "dazzled"
class TestRangedTouchDelivery:
"""Ranged touch attacks (CRB, Magic): a ray spell requires a ranged touch
attack (BAB + Dex vs touch AC); auto-hit spells like Magic Missile do not."""
def test_missed_ranged_touch_attack_wastes_the_spell(self) -> None:
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Dex 10)
# When: Ray is cast with an attack roll of 5 (total 6)
# Then: the spell misses, deals no damage, and the slot is spent.
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
engine._cast_spell(c, f, "Ray")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"ranged touch attack vs foe" in line and "MISS" in line
for line in engine._transcript
)
def test_successful_ranged_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy
# When: the ranged touch attack roll is 10 (total 11 >= 10)
# Then: the ray lands for its 1d6.
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [10, 4])
engine._cast_spell(c, f, "Ray")
assert f.hp == 100 - 4
assert any(
"ranged touch attack vs foe" in line and "HIT" in line
for line in engine._transcript
)
def test_ranged_touch_on_ally_needs_no_attack_roll(self) -> None:
# Given: an ally on the caster's side
# When: a ray is cast on them with an EMPTY die queue
# Then: no attack roll is consumed and the effect lands (willing).
c = _state(_caster(), "players", (0, 0))
ally = _state(_foe(), "players", (0, 1))
engine = _engine([c, ally], [4])
engine._cast_spell(c, ally, "Ray")
assert ally.hp == 100 - 4
assert not any("ranged touch attack" in line for line in engine._transcript)
def test_magic_missile_still_auto_hits(self) -> None:
# Given: the real Magic Missile spell (no ranged touch requirement)
# When: cast with only a damage roll in the queue
# Then: no attack roll is consumed — the missile always hits.
missile = load_spell(SPELLS_DIR / "magic_missile.json")
assert missile.requires_ranged_touch is False
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = CombatEngine(
ScriptedRng([3]), # 1d4+1 -> 4, no attack roll before it
_grid(),
[c, f],
spell_registry={"Magic Missile": missile},
)
engine._cast_spell(c, f, "Magic Missile")
assert f.hp == 100 - 4
assert not any("ranged touch attack" in line for line in engine._transcript)
def test_requires_ranged_touch_parses_from_spell_json(self) -> None:
# Given: the shipped spell data
# When: ray spells and non-rays are loaded
# Then: the flag reflects the SRD delivery (rays True, others False).
assert load_spell(SPELLS_DIR / "ray_of_frost.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "scorching_ray.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "acid_arrow.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "fireball.json").requires_ranged_touch is False
assert SpellSpec(name="X", level=0, school="evocation").requires_ranged_touch is False
class TestDefensiveAndGrappleCasting:
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
Concentration). Combat Casting's +4 applies to both."""
def test_defensive_cast_skips_aoo_and_lands_on_passed_check(self) -> None:
# Given: a threatened caster (adjacent armed enemy)
# When: Bolt is cast defensively (DC 17) with roll 8 + lvl 5 + int 4
# Then: no AoO is provoked and the spell resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3]) # concentration, then 2d4
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_defensive_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same threatened caster
# When: the defensive check fails (roll 1 + 5 + 4 = 10 < 17)
# Then: the spell is lost, the slot spent, no damage roll happens.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [1])
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting defensively: concentration failed (DC 17), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_defensive_check(self) -> None:
# Given: a wis-based caster (mod 0) with and without Combat Casting
# When: the defensive check roll is 8 (DC 17)
# Then: with the feat 8+5+4 = 17 passes; without, 13 fails.
plain = _caster(casting_ability="wis", int_score=10, combat_casting=False)
pc = _state(plain, "players", (0, 0))
pf = _state(_foe(), "monsters", (0, 1))
engine_fail = _engine([pc, pf], [8])
engine_fail._cast_spell(pc, pf, "Bolt", cast_defensively=True)
assert pf.hp == 100
feat = _caster(casting_ability="wis", int_score=10, combat_casting=True)
fc = _state(feat, "players", (0, 0))
ff = _state(_foe(), "monsters", (0, 1))
engine_pass = _engine([fc, ff], [8, 3, 3])
engine_pass._cast_spell(fc, ff, "Bolt", cast_defensively=True)
assert ff.hp == 100 - 6
def test_grappled_caster_must_pass_the_grapple_check(self) -> None:
# Given: a caster grappled by an enemy with CMB 7 (DC 10+7+1 = 18)
# When: the check roll is 9 (9 + lvl 5 + int 4 = 18)
# Then: the cast succeeds — and no AoO path is taken.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_grappled_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same grappled caster
# When: the check roll is 8 (17 < 18)
# Then: the spell is lost with the grapple fizzle log.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [8])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting while grappled: concentration failed (DC 18), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_grapple_check(self) -> None:
# Given: a grappled wis-based caster (mod 0) with Combat Casting
# When: the check roll is 9 (9 + 5 + 4 = 18 >= DC 18)
# Then: the cast lands.
caster = _caster(casting_ability="wis", int_score=10, combat_casting=True)
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100 - 6
def test_normal_cast_still_provokes_the_aoo(self) -> None:
# Given: a threatened caster NOT casting defensively
# When: Bolt is cast (enemy AoO roll 2 misses AC 12)
# Then: the AoO is provoked and the spell still resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [2, 3, 3]) # AoO d20, then 2d4
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used.get("foe") == 1
assert f.hp == 100 - 6
def test_action_cast_defensively_threads_through_execution(self) -> None:
# Given: a threatened caster
# When: the cast is issued as an Action with cast_defensively
# Then: _execute dispatches the defensive cast (no AoO, check made).
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
cast_defensively=True,
),
)
assert result.ok is True
assert engine._aoo_used == {}
assert f.hp == 100 - 6