From 6f784d3369d71030864e69e4568cb84f7ceb9943 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 20:19:03 +0200 Subject: [PATCH] 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 --- README.md | 18 ++++- data/spells/acid_arrow.json | 1 + data/spells/acid_splash.json | 1 + data/spells/disrupt_undead.json | 1 + data/spells/ray_of_enfeeblement.json | 1 + data/spells/ray_of_frost.json | 1 + data/spells/scorching_ray.json | 1 + data/spells/shard_of_chaos.json | 1 + data/spells/spear_of_purity.json | 1 + src/pf1e_simulator/combat.py | 46 ++++++++++++- src/pf1e_simulator/spells.py | 5 ++ tests/test_conformance_spellflow.py | 98 +++++++++++++++++++++++++++- 12 files changed, 168 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 41a30c0..c02ad83 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/data/spells/acid_arrow.json b/data/spells/acid_arrow.json index 236daa0..9467688 100644 --- a/data/spells/acid_arrow.json +++ b/data/spells/acid_arrow.json @@ -5,6 +5,7 @@ "range": "medium", "save": "none", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/acid_splash.json b/data/spells/acid_splash.json index 12b1f67..2ecde81 100644 --- a/data/spells/acid_splash.json +++ b/data/spells/acid_splash.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/disrupt_undead.json b/data/spells/disrupt_undead.json index 93e93b8..bcbc4d9 100644 --- a/data/spells/disrupt_undead.json +++ b/data/spells/disrupt_undead.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/ray_of_enfeeblement.json b/data/spells/ray_of_enfeeblement.json index a7fca83..ed0663a 100644 --- a/data/spells/ray_of_enfeeblement.json +++ b/data/spells/ray_of_enfeeblement.json @@ -5,6 +5,7 @@ "range": "close", "save": "fort", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/ray_of_frost.json b/data/spells/ray_of_frost.json index ef6c565..8f11d0d 100644 --- a/data/spells/ray_of_frost.json +++ b/data/spells/ray_of_frost.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/scorching_ray.json b/data/spells/scorching_ray.json index 4a16846..bc867c9 100644 --- a/data/spells/scorching_ray.json +++ b/data/spells/scorching_ray.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "requires_ranged_touch": true, "cast_time": "standard", "effects": [ { diff --git a/data/spells/shard_of_chaos.json b/data/spells/shard_of_chaos.json index f9d73c0..c8ec60d 100644 --- a/data/spells/shard_of_chaos.json +++ b/data/spells/shard_of_chaos.json @@ -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." diff --git a/data/spells/spear_of_purity.json b/data/spells/spear_of_purity.json index 166f755..690d225 100644 --- a/data/spells/spear_of_purity.json +++ b/data/spells/spear_of_purity.json @@ -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." diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f65cb92..3e9213d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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, diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 89375f1..9176473 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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", "")), diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py index 50fc98b..528b5e4 100644 --- a/tests/test_conformance_spellflow.py +++ b/tests/test_conformance_spellflow.py @@ -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,