fix(rules): apply cover concealment and criticals to touch attacks

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': a touch spell threatens on a natural 20 and deals double damage on a successful confirmation. The melee and ranged spell-touch gates now add the target's cover bonus to touch AC (new _attack_cover_bonus helper shared with resolve_attack), roll the concealment miss chance after the hit (blinded attacker = total concealment convention), and resolve crit threat/confirm with damage doubling for damage-dealing touch spells. Non-damage touch spells do not threaten (no die consumed). Golden transcript unaffected.

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-12 13:47:03 +02:00
parent 35c8d101b5
commit ec2088ab90
3 changed files with 323 additions and 59 deletions
+13 -1
View File
@@ -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
+155 -58
View File
@@ -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:
+155
View File
@@ -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