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,