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
+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", "")),