diff --git a/README.md b/README.md index 30ec3ef..af34cef 100644 --- a/README.md +++ b/README.md @@ -844,7 +844,19 @@ Règles modélisées : 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 + ne sont pas modélisés. Conformance L3 (CRB « Cast a Spell — Touch Attacks », + « Cover », « Concealment », « Range — Touch ») : le jet de contact (mêlée ou + distance) est une **attaque** — la CA de contact inclut le **couvert** de la + cible (terrain +2/+4/+8 et couvert mou +4 à distance, même chemin + `_attack_cover_bonus` que `resolve_attack` ; mêlée = ligne terrain seule), la + **dissimulation** peut faire rater l'attaque après la touche (jet de d100, + convention `max(concealment de la cible, 50)` si l'attaquant est `blinded`, + journalisé « spell lost » / « spell delivered »), et un sort de contact qui + **inflige des dégâts** menace le critique sur un 20 naturel et **double ses + dégâts** sur confirmation réussie (le jet de confirmation est un jet + d'attaque contre la même CA ; un sort de contact sans dégâts — Grace, + Ray of Enfeeblement — ne menace pas, aucun dé consommé). 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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 36fd9eb..29589e4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2436,18 +2436,8 @@ class CombatEngine: ac = base_ac + resolve_modifiers(ac_mods) ac += target.defensive_ac_bonus ac += target.aid_ac_bonus - occupied = frozenset( - cell - for s in self._states - if s.active and s is not attacker and s is not target - for cell in s.occupied_cells() - ) - ac += cover_bonus_cells( - self._grid, - attacker.occupied_cells(), - target.occupied_cells(), - ranged=self._is_ranged_attack(weapon), - occupied=occupied, + ac += self._attack_cover_bonus( + attacker, target, ranged=self._is_ranged_attack(weapon) ) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) damage = 0 @@ -2595,6 +2585,25 @@ class CombatEngine: damage, weapon, rolls, target=target ) + def _attack_cover_bonus( + self, attacker: CombatantState, target: CombatantState, *, ranged: bool + ) -> int: + """Cover the target has against one attack from ``attacker`` (terrain + + soft cover from interposed creatures on ranged lines only).""" + occupied = frozenset( + cell + for s in self._states + if s.active and s is not attacker and s is not target + for cell in s.occupied_cells() + ) + return cover_bonus_cells( + self._grid, + attacker.occupied_cells(), + target.occupied_cells(), + ranged=ranged, + occupied=occupied, + ) + def _concealment_chance(self, target: CombatantState) -> int: """Highest miss chance (%) among the target's active concealment conditions.""" chances = [ac.condition.miss_chance for ac in target.active_conditions] @@ -5202,14 +5211,16 @@ class CombatEngine: ): return None # PF1e (CRB): hostile touch delivery needs a melee touch attack (B14) - # and spell resistance can still turn the spell away. - if self._spell_delivery_blocked( + # and spell resistance can still turn the spell away. A confirmed + # touch crit doubles the spell's damage (CRB "Range — Touch"). + blocked, delivery_crit = self._spell_delivery_blocked( caster, target, spell, has_area=has_area, attack_penalty=attack_penalty - ): + ) + if blocked: return None self._apply_and_log_cast( caster, target, spell, spell_name, center_pos, - metamagic=applied, slot_level=used_level, + metamagic=applied, slot_level=used_level, spell_crit=delivery_crit, ) return None @@ -5247,36 +5258,47 @@ class CombatEngine: *, has_area: bool, attack_penalty: int = 0, - ) -> bool: + ) -> tuple[bool, bool]: """Delivery gates: a hostile touch spell must land its melee touch 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). ``attack_penalty`` applies to - both touch rolls (Spell Combat).""" + ranged touch attack (cover, concealment and crits included), then + spell resistance must be overcome. Returns ``(blocked, crit)``: + True blocked = no effect (the slot is already spent); ``crit`` is True + when a damage-dealing touch delivery was confirmed on a natural 20 + (the spell's damage dice are then doubled).""" + crit = False + deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects) if ( target is not None and spell.range == "touch" and target.side != caster.side - and not self._spell_touch_attack_hits( - caster, target, attack_penalty=attack_penalty - ) ): - return True + hit, crit = self._spell_touch_attack_hits( + caster, target, + attack_penalty=attack_penalty, deals_damage=deals_damage, + ) + if not hit: + return True, False 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, attack_penalty=attack_penalty - ) ): - return True - return ( + hit, ranged_crit = self._spell_ranged_touch_attack_hits( + caster, target, + attack_penalty=attack_penalty, deals_damage=deals_damage, + ) + if not hit: + return True, False + crit = ranged_crit + if ( target is not None and not has_area and not self._check_spell_resistance(caster, target) - ) + ): + return True, False + return False, crit def _spell_touch_attack_hits( self, @@ -5284,17 +5306,25 @@ class CombatEngine: target: CombatantState, *, attack_penalty: int = 0, - ) -> bool: + deals_damage: bool = False, + ) -> tuple[bool, bool]: """Melee touch attack to deliver a touch-range spell (B14). PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch - AC. ``attack_penalty`` folds into the bonus (Spell Combat: "any - attack roll made as part of this spell also takes this penalty"). - Engine simplification (documented): the bonus is BAB + Strength - modifier — Weapon Finesse and other situational touch 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). + AC, PLUS the target's cover bonus (a touch attack is an attack roll — + CRB "Cover") and subject to the target's concealment miss chance (CRB + "Concealment"); a spell that deals damage threatens on a natural 20 + and deals double damage on a successful confirmation (CRB "Cast a + Spell — Touch Attacks": "You can score critical hits with either type + of attack as long as the spell deals damage"; CRB "Range — Touch"). + ``attack_penalty`` folds into the bonus (Spell Combat: "any attack + roll made as part of this spell also takes this penalty"). Engine + simplification (documented): the bonus is BAB + Strength modifier — + Weapon Finesse and other situational touch 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). + Returns ``(hit, crit)``. """ roll = self._rng.d20() bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty @@ -5307,13 +5337,11 @@ class CombatEngine: 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}: touch attack vs " - f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} " - f"-> {'HIT' if hit else 'MISS'}" + ac += self._attack_cover_bonus(caster, target, ranged=False) + return self._resolve_touch_delivery( + caster, target, label="touch attack", + roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage, ) - return hit def _spell_ranged_touch_attack_hits( self, @@ -5321,19 +5349,24 @@ class CombatEngine: target: CombatantState, *, attack_penalty: int = 0, - ) -> bool: + deals_damage: bool = False, + ) -> tuple[bool, 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. ``attack_penalty`` folds - into the bonus (Spell Combat: "any attack roll made as part of this - spell also takes this 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). + bonus vs the target's touch AC, PLUS the target's ranged cover bonus + (terrain + soft cover, CRB "Cover") and subject to the concealment + miss chance (CRB "Concealment"); a ray that deals damage can score a + critical hit (CRB "Cast a Spell — Touch Attacks"). The spell's range + is a single absolute value so there is no range-increment penalty. + ``attack_penalty`` folds into the bonus (Spell Combat: "any attack + roll made as part of this spell also takes this 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). + Returns ``(hit, crit)``. """ roll = self._rng.d20() bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty @@ -5346,13 +5379,62 @@ class CombatEngine: ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) else: ac = target.combatant.ac.touch + ac += self._attack_cover_bonus(caster, target, ranged=True) + return self._resolve_touch_delivery( + caster, target, label="ranged touch attack", + roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage, + ) + + def _resolve_touch_delivery( + self, + caster: CombatantState, + target: CombatantState, + *, + label: str, + roll: int, + bonus: int, + ac: int, + deals_damage: bool, + ) -> tuple[bool, bool]: + """Finish a touch-attack roll: hit check, crit threat/confirm (only + when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then + the concealment miss chance (a blinded caster faces total concealment, + same max(target, 50) convention as _resolve_hit_damage).""" 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"round {self._current_round} {caster.combatant.id}: {label} vs " f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} " f"-> {'HIT' if hit else 'MISS'}" ) - return hit + if not hit: + return False, False + crit = False + if deals_damage and roll == _NATURAL_TWENTY: + confirm = self._rng.d20() + crit = confirm == _NATURAL_TWENTY or ( + confirm != _NATURAL_ONE and confirm + bonus >= ac + ) + self._log( + f"round {self._current_round} {caster.combatant.id}: {label} " + f"crit confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} " + f"-> {'CRIT' if crit else 'no crit'}" + ) + concealment = self._concealment_chance(target) + blinded = self._has_condition_named(caster, "blinded") + miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment + if miss_chance > 0: + pct = self._rng.d100() + missed = pct <= miss_chance + source = "attacker blinded" if blinded else "concealment" + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"{source} vs {target.combatant.id}: " + f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% " + f"— {'spell lost' if missed else 'spell delivered'}" + ) + if missed: + return False, False + return True, crit def _spell_cast_rejected( self, @@ -5425,13 +5507,16 @@ class CombatEngine: *, metamagic: tuple[str, ...] = (), slot_level: int | None = None, + spell_crit: bool = False, ) -> None: """Resolve save, apply effects and log a spell cast (post-gates). ``metamagic`` holds the feats chosen for THIS cast (already filtered to known ones, B7); ``slot_level`` is the spell's level in the slot used — the save DC is computed from it (PF1e CRB: a metamagic spell - uses the DC of the higher slot level). + uses the DC of the higher slot level). ``spell_crit`` is a confirmed + touch-delivery critical: the spell's damage dice are doubled (CRB + "Range — Touch": "deals double damage on a successful critical hit"). """ dc = self._spell_dc(caster, spell, slot_level=slot_level) save_result = ( @@ -5454,6 +5539,7 @@ class CombatEngine: empower="Empower Spell" in metamagic, maximize="Maximize Spell" in metamagic, dc=dc, center_pos=center_pos, save_type=spell.save, + crit=spell_crit, ) save_str = _save_label(save_result) if target is None and center_pos is not None: @@ -5509,6 +5595,7 @@ class CombatEngine: dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", + crit: bool = False, ) -> list[str]: saved = save_result is not None and save_result.success return [ @@ -5517,6 +5604,7 @@ class CombatEngine: caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, dc=dc, center_pos=center_pos, save_type=save_type, + crit=crit, ) for effect in effects ] @@ -5536,6 +5624,7 @@ class CombatEngine: dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", + crit: bool = False, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( @@ -5551,7 +5640,7 @@ class CombatEngine: return self._apply_targeted_spell_effect( effect, target, caster_live, target_live, caster=caster, saved=saved, empower=empower, maximize=maximize, - spell_name=spell_name, + spell_name=spell_name, crit=crit, ) def _apply_targeted_spell_effect( @@ -5566,11 +5655,13 @@ class CombatEngine: empower: bool, maximize: bool, spell_name: str, + crit: bool = False, ) -> str: if isinstance(effect, DamageEffect): return self._apply_damage_effect( effect, target, caster_live, target_live, saved=saved, empower=empower, maximize=maximize, caster=caster, + crit=crit, ) if isinstance(effect, ConditionEffect): return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) @@ -5589,10 +5680,16 @@ class CombatEngine: empower: bool, maximize: bool, caster: CombatantState, + crit: bool = False, ) -> str: raw = effect.formula.roll(self._rng) if maximize: raw = effect.formula.max_value() + if crit: + # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that + # deals damage threatens on a natural 20 and deals double damage + # on a successful critical hit. + raw *= 2 if saved and effect.half_on_save: dmg = max(0, raw // 2) elif saved and effect.save_negates: diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py index 528b5e4..50f0e38 100644 --- a/tests/test_conformance_spellflow.py +++ b/tests/test_conformance_spellflow.py @@ -393,6 +393,161 @@ class TestRangedTouchDelivery: assert ally.hp == 100 - 4 assert not any("ranged touch attack" in line for line in engine._transcript) + +class TestTouchDeliveryDefense: + """L3 conformance: a touch/ranged-touch attack is an attack roll, so the + target keeps its cover bonus (CRB "Cover"), concealment can make it miss + (CRB "Concealment"), and a damage-dealing touch spell threatens on a + natural 20 and deals double damage on confirmation (CRB "Cast a Spell — + Touch Attacks", "Range — Touch").""" + + def _wall_engine( + self, queue: list[int] + ) -> tuple[CombatEngine, CombatantState, CombatantState]: + legend = { + ".": TerrainType(type="floor", move_cost=1), + "#": TerrainType(type="wall", move_cost=None, blocks_los=True), + "W": TerrainType(type="ruin", move_cost=None, cover_bonus=2), + } + grid = Grid.from_spec( + MapSpec( + name="cover", + terrain=("..W..", "..W..", ".#...", "....."), + legend=legend, + ) + ) + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (1, 4)) + return ( + CombatEngine(ScriptedRng(queue), grid, [c, f], spell_registry=_REGISTRY), + c, + f, + ) + + def test_ranged_touch_terrain_cover_raises_touch_ac(self) -> None: + # Given: ruins grazing the firing line (+2 partial cover, LoE intact) + # When: Ray is cast with roll 10 (total 11) + # Then: the attack is resolved against touch AC 10 + 2 = 12 and misses. + engine, c, f = self._wall_engine([10]) + engine._cast_spell(c, f, "Ray") + assert f.hp == 100 + assert any( + "ranged touch attack vs foe" in line and "AC 12" in line + for line in engine._transcript + ) + + def test_ranged_touch_soft_cover_from_interposed_ally(self) -> None: + # Given: an ally standing between the caster and the target + # When: Ray is cast with roll 5 (total 6) + # Then: the target gains soft cover +4 — resolved vs AC 14, miss. + c = _state(_caster(), "players", (0, 0)) + ally = _state(_foe(), "players", (0, 1)) + f = _state(_foe(melee=False), "monsters", (0, 2)) + engine = _engine([c, ally, f], [5]) + engine._cast_spell(c, f, "Ray") + assert f.hp == 100 + assert any( + "ranged touch attack vs foe" in line and "AC 14" in line + for line in engine._transcript + ) + + def test_concealment_makes_a_landed_touch_spell_miss(self) -> None: + # Given: a blurred target (20% concealment); the touch roll hits + # When: the concealment d100 comes up under the miss chance + # Then: the spell is lost (slot burned, no effect). + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + f.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["blurred"], + rounds_left=5, + source="test", + ) + ) + engine = _engine([c, f], [15, 10]) + engine._cast_spell(c, f, "Zap") + assert f.hp == 100 + assert c.spell_slots_left[1] == 0 + assert any( + "concealment vs foe" in line and "spell lost" in line + for line in engine._transcript + ) + + def test_concealment_high_d100_delivers_the_touch_spell(self) -> None: + # Given: the same blurred target, hit roll and a high concealment die + # When: Zap resolves + # Then: the spell lands for its normal 1d6 (4). + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + f.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["blurred"], + rounds_left=5, + source="test", + ) + ) + engine = _engine([c, f], [15, 90, 4]) + engine._cast_spell(c, f, "Zap") + assert f.hp == 100 - 4 + assert any( + "concealment vs foe" in line and "spell delivered" in line + for line in engine._transcript + ) + + def test_touch_nat20_confirmed_crit_doubles_damage(self) -> None: + # Given: a damage-dealing touch spell (Zap 1d6), natural 20 threatens + # When: the confirm roll (10+1 >= 10) succeeds + # Then: the spell deals double damage (4 -> 8). + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [20, 10, 4]) + engine._cast_spell(c, f, "Zap") + assert f.hp == 100 - 8 + assert any( + "touch attack crit confirm" in line and "-> CRIT" in line + for line in engine._transcript + ) + + def test_touch_crit_failed_confirmation_deals_normal_damage(self) -> None: + # Given: natural 20 threatens + # When: the confirm roll is a natural 1 (auto-fail) + # Then: the spell still hits for normal damage (4). + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [20, 1, 4]) + engine._cast_spell(c, f, "Zap") + assert f.hp == 100 - 4 + assert any( + "touch attack crit confirm" in line and "-> no crit" in line + for line in engine._transcript + ) + + def test_ranged_touch_nat20_crit_doubles_damage(self) -> None: + # Given: a damage ray (1d6), natural 20 threatens + # When: the confirm succeeds + # Then: double damage (4 -> 8). + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [20, 10, 4]) + engine._cast_spell(c, f, "Ray") + assert f.hp == 100 - 8 + assert any( + "ranged touch attack crit confirm" in line and "-> CRIT" in line + for line in engine._transcript + ) + + def test_non_damage_touch_spell_does_not_threaten(self) -> None: + # Given: a condition-only touch spell (Grace) cast on a natural 20 + # When: the delivery resolves with only the attack die in the queue + # Then: no confirmation roll is consumed (CRB: criticals only for + # spells that deal damage) and the condition still lands. + c = _state(_caster(), "players", (0, 0)) + f = _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [20]) + engine._cast_spell(c, f, "Grace") + assert any("dazzled" in line for line in engine._transcript) + assert not any("crit confirm" 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