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
+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: