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:
+155
-58
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user