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>
This commit is contained in:
2026-09-11 20:19:03 +02:00
parent 24af1687ab
commit 6f784d3369
12 changed files with 168 additions and 7 deletions
+95 -3
View File
@@ -6,6 +6,10 @@ B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
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
@@ -31,9 +35,11 @@ from pf1e_simulator.models import (
)
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
@@ -66,7 +72,16 @@ _GRACE = SpellSpec(
),
),
)
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE}
_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(
@@ -150,11 +165,15 @@ def _foe(
)
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
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.from_spec(spec), states, spell_registry=_REGISTRY
ScriptedRng(queue), _grid(), states, spell_registry=_REGISTRY
)
@@ -330,6 +349,79 @@ class TestHostileTouchDelivery:
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,