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:
@@ -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,
|
||||
|
||||
@@ -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", "")),
|
||||
|
||||
Reference in New Issue
Block a user