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
+16 -2
View File
@@ -745,7 +745,20 @@ Règles modélisées :
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
est brûlé). Simplifications documentées : Weapon Finesse et les autres
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
volontaire (soi ou un allié du même camp) ne demande aucun jet. Un sort ciblé avec sauvegarde fait pleins dégâts
volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à
contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le
JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of
Frost, Ray of Enfeeblement, Scorching Ray, Acid Arrow, Acid Splash, Disrupt
Undead, Shard of Chaos, Spear of Purity) impose une attaque de contact à
distance contre une cible **hostile** (hors effet de zone) — d20 + BAB + mod
de **Dextérité** du lanceur contre la CA de contact de la cible (amputée du
modificateur de Dextérité si la cible perd son bonus de Dextérité, même
chemin que `resolve_attack`) ; la portée du sort est une valeur absolue, il
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
zone et les sorts à sauvegarde simple ne sont pas concernés. Un sort ciblé avec sauvegarde fait pleins dégâts
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
@@ -1127,7 +1140,8 @@ la lecture mais non appliquée dans la résolution.
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base, temps d'incantation `cast_time`) et types d'effets
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
+1
View File
@@ -5,6 +5,7 @@
"range": "medium",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "fort",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [
{
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "will",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [],
"description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "will",
"base_dc": 10,
"requires_ranged_touch": true,
"cast_time": "standard",
"effects": [],
"description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
+44 -2
View File
@@ -4698,8 +4698,9 @@ class CombatEngine:
has_area: bool,
) -> bool:
"""Delivery gates: a hostile touch spell must land its melee touch
attack (B14), then spell resistance must be overcome. True = no
effect (the slot is already spent)."""
attack (B14) and a hostile ranged touch spell (ray) must land its
ranged touch attack, then spell resistance must be overcome. True =
no effect (the slot is already spent)."""
if (
target is not None
and spell.range == "touch"
@@ -4707,6 +4708,14 @@ class CombatEngine:
and not self._spell_touch_attack_hits(caster, target)
):
return True
if (
target is not None
and spell.requires_ranged_touch
and not has_area
and target.side != caster.side
and not self._spell_ranged_touch_attack_hits(caster, target)
):
return True
return (
target is not None
and not has_area
@@ -4744,6 +4753,39 @@ class CombatEngine:
)
return hit
def _spell_ranged_touch_attack_hits(
self, caster: CombatantState, target: CombatantState
) -> bool:
"""Ranged touch attack to deliver a ray spell.
PF1e (CRB, Magic — Ranged Touch Attacks): d20 + caster's ranged attack
bonus vs the target's touch AC; the spell's range is a single absolute
value so there is no range-increment penalty. Engine simplification
(documented): the bonus is BAB + Dexterity modifier — size modifier,
Weapon Finesse and other situational bonuses are not modeled; natural
20 hits and natural 1 misses per the engine's attack-roll convention.
The touch AC reuses the resolve_attack denies-Dex path (flat-footed/
running target, invisible attacker).
"""
roll = self._rng.d20()
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._has_condition_named(caster, "invisible")
)
if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
else:
ac = target.combatant.ac.touch
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
self._log(
f"round {self._current_round} {caster.combatant.id}: ranged touch attack vs "
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
f"-> {'HIT' if hit else 'MISS'}"
)
return hit
def _spell_cast_rejected(
self,
caster: CombatantState,
+5
View File
@@ -95,6 +95,10 @@ class SpellSpec:
range: SpellRange = "medium"
save: SaveType = "none"
base_dc: int = 10
# PF1e (CRB, Magic — Ranged Touch Attacks): the spell requires a ranged
# touch attack to deliver (rays). Auto-hit spells (Magic Missile) and
# area/save-only spells keep this False.
requires_ranged_touch: bool = False
cast_time: CastTime = "standard"
effects: tuple[SpellEffect, ...] = ()
description: str = ""
@@ -304,6 +308,7 @@ def load_spell(path: str | Path) -> SpellSpec:
range=range_raw,
save=save_raw,
base_dc=_as_int(data.get("base_dc", 10)),
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
cast_time=cast_time,
effects=effects,
description=_as_str(data.get("description", "")),
+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,