fix(rules): a missed melee touch attack holds the charge instead of expending the spell (SRD Holding the Charge: re-attempted at the start of later turns, dissipated by casting another spell; ranged touch still expended on a miss)
This commit is contained in:
@@ -686,6 +686,27 @@ class PendingCast:
|
||||
hp_at_cast: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeldCharge:
|
||||
"""A touch spell whose melee touch attack missed - the charge is held.
|
||||
|
||||
PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): "If you
|
||||
don't discharge the spell in the round when you cast the spell, you can
|
||||
hold the charge indefinitely. You can continue to make touch attacks
|
||||
round after round." A missed melee touch therefore does NOT expend the
|
||||
spell (the slot was spent at the cast; the spell itself survives to be
|
||||
re-attempted). Casting another spell dissipates it ("If you cast another
|
||||
spell, the touch spell dissipates"). Ranged touch attacks are NOT held:
|
||||
SRD "Ranged Touch Spells in Combat" - "ranged touch attacks cannot be
|
||||
held until a later turn".
|
||||
"""
|
||||
|
||||
spell_name: str
|
||||
target_id: str
|
||||
metamagic: tuple[str, ...]
|
||||
slot_level: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class CombatantState:
|
||||
"""Mutable runtime state of one combatant on the grid."""
|
||||
@@ -780,6 +801,12 @@ class CombatantState:
|
||||
# model): the spell begun on this combatant's turn, resolved just before
|
||||
# its NEXT turn begins; None when no casting is in progress.
|
||||
pending_cast: PendingCast | None = None
|
||||
# PF1e held touch charge (SRD "Cast a Spell - Touch Attacks", Holding the
|
||||
# Charge, not in the frozen data model): a touch spell whose melee touch
|
||||
# attack missed; re-attempted at the start of each later turn of this
|
||||
# combatant while the target lives and stands in reach, dissipated when
|
||||
# this combatant casts another spell. None when no charge is held.
|
||||
held_charge: HeldCharge | None = None
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
return [
|
||||
@@ -1748,6 +1775,49 @@ class CombatEngine:
|
||||
metamagic=pending.metamagic, slot_level=pending.slot_level,
|
||||
)
|
||||
|
||||
def _resolve_held_charge(self, caster: CombatantState) -> None:
|
||||
"""PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): a
|
||||
missed melee touch attack leaves the charge held - "You can continue
|
||||
to make touch attacks round after round." Re-attempt it at the start
|
||||
of each of the caster's later turns while the held target is alive
|
||||
and within the spell's touch range; a miss re-sets the charge, a hit
|
||||
delivers through the normal gates, and an SR suppression consumes it
|
||||
(the spell discharges into the resistance).
|
||||
|
||||
Documented simplifications: the re-attempt is engine-driven at turn
|
||||
start instead of costing a standard action; discharge-on-any-touch
|
||||
and unarmed-attack interactions while holding are not modeled; the
|
||||
charge is not exposed in the Observation."""
|
||||
held = caster.held_charge
|
||||
if held is None:
|
||||
return
|
||||
spell = self._spell_registry.get(held.spell_name)
|
||||
if spell is None:
|
||||
caster.held_charge = None
|
||||
return
|
||||
target = next(
|
||||
(s for s in self._states if s.combatant.id == held.target_id), None
|
||||
)
|
||||
if target is None or not target.active:
|
||||
caster.held_charge = None
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"held charge of {held.spell_name} dissipates (target gone)"
|
||||
)
|
||||
return
|
||||
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
||||
if dist_ft > spell_range_ft(spell, caster.combatant.level):
|
||||
return # out of reach: still holding, re-attempt a later turn
|
||||
caster.held_charge = None # consumed by this attempt; a miss re-sets
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"re-attempts held charge {held.spell_name}"
|
||||
)
|
||||
self._deliver_cast(
|
||||
caster, target, spell, held.spell_name, None,
|
||||
metamagic=held.metamagic, slot_level=held.slot_level,
|
||||
)
|
||||
|
||||
def _forfeit_pending_cast(self, caster: CombatantState) -> None:
|
||||
"""PF1e (SRD "Casting Time"): "you must continue the concentration
|
||||
from the current round to just before your turn in the next round
|
||||
@@ -1785,6 +1855,9 @@ class CombatEngine:
|
||||
# the turn; the value is the battle-over flag.
|
||||
state.last_action_results = []
|
||||
return gate
|
||||
# SRD "Holding the Charge": a held touch spell is re-attempted at
|
||||
# the start of a later turn (before the caster acts).
|
||||
self._resolve_held_charge(state)
|
||||
self._apply_fear_aura(state)
|
||||
state.moved_this_turn = False
|
||||
state.power_attack = False
|
||||
@@ -5990,6 +6063,15 @@ class CombatEngine:
|
||||
kind="cast_spell", ok=False, reason="no_slot",
|
||||
detail=f"level {used_level}",
|
||||
)
|
||||
# PF1e (SRD "Holding the Charge"): "If you cast another spell, the
|
||||
# touch spell dissipates."
|
||||
if caster.held_charge is not None:
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"held charge of {caster.held_charge.spell_name} dissipates "
|
||||
f"(casting {spell_name})"
|
||||
)
|
||||
caster.held_charge = None
|
||||
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
|
||||
# must clear their interruption gate before the spell is delivered.
|
||||
if self._spell_cast_interrupted(
|
||||
@@ -6047,7 +6129,9 @@ class CombatEngine:
|
||||
"""
|
||||
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
||||
blocked, delivery_crit = self._spell_delivery_blocked(
|
||||
caster, target, spell, has_area=has_area, attack_penalty=attack_penalty
|
||||
caster, target, spell, spell_name, has_area=has_area,
|
||||
attack_penalty=attack_penalty,
|
||||
metamagic=metamagic, slot_level=slot_level,
|
||||
)
|
||||
if blocked:
|
||||
return
|
||||
@@ -6100,17 +6184,25 @@ class CombatEngine:
|
||||
caster: CombatantState,
|
||||
target: CombatantState | None,
|
||||
spell: SpellSpec,
|
||||
spell_name: str,
|
||||
*,
|
||||
has_area: bool,
|
||||
attack_penalty: int = 0,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
slot_level: int | None = None,
|
||||
) -> 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 (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)."""
|
||||
True blocked = no effect; ``crit`` is True when a damage-dealing
|
||||
touch delivery was confirmed on a natural 20 (the spell's damage
|
||||
dice are then doubled). A MISSED melee touch does not expend the
|
||||
spell - SRD "Holding the Charge": "you can hold the charge
|
||||
indefinitely. You can continue to make touch attacks round after
|
||||
round" - so the caster keeps a HeldCharge (the slot stays spent).
|
||||
A missed ranged touch IS expended (SRD: "ranged touch attacks
|
||||
cannot be held until a later turn")."""
|
||||
crit = False
|
||||
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
||||
# PF1e multi-projectile rays (Scorching Ray): each projectile rolls its
|
||||
@@ -6127,6 +6219,16 @@ class CombatEngine:
|
||||
attack_penalty=attack_penalty, deals_damage=deals_damage,
|
||||
)
|
||||
if not hit:
|
||||
caster.held_charge = HeldCharge(
|
||||
spell_name=spell_name,
|
||||
target_id=target.combatant.id,
|
||||
metamagic=metamagic,
|
||||
slot_level=spell.level if slot_level is None else slot_level,
|
||||
)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"holds the charge of {spell_name}"
|
||||
)
|
||||
return True, False
|
||||
if (
|
||||
target is not None
|
||||
|
||||
Reference in New Issue
Block a user