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:
2026-09-19 11:40:02 +02:00
parent 81a09c88f0
commit 418fdc826b
3 changed files with 208 additions and 14 deletions
+15 -3
View File
@@ -1003,9 +1003,21 @@ Règles modélisées :
différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force
du lanceur contre la CA de contact de la cible (amputée du modificateur de du lanceur contre la CA de contact de la cible (amputée du modificateur de
Dextérité si la cible perd son bonus de Dextérité, même chemin que Dextérité si la cible perd son bonus de Dextérité, même chemin que
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement `resolve_attack`) ; en cas d'échec le sort **n'est pas dépensé** : le
est brûlé). Simplifications documentées : Weapon Finesse et les autres lanceur **garde la charge** (SRD « Holding the Charge » : « If the attack
bonus situationnels de jet de contact ne sont pas modélisés. Une cible misses, you are still holding the charge [...] You can continue to make
touch attacks round after round ») — l'état `held_charge` du lanceur
(sort, cible, métamagie, niveau d'emplacement) est rejoué automatiquement
au **début de chacun de ses tours suivants** tant que la cible vit et
reste à portée de contact ; une nouvelle incantation **dissipe** la charge
(« If you cast another spell, the touch spell dissipates ») et une cible
morte la dissipe aussi (journalisé `dissipates`). L'emplacement reste
brûlé (dépensé à l'incantation). Simplifications documentées : Weapon
Finesse et les autres bonus situationnels de jet de contact ne sont pas
modélisés ; la re-tentative est pilotée par le moteur au début du tour
(coût RAW : action standard) ; la décharge sur tout contact (même
involontaire) et les attaques à l'arme tenue pendant la charge ne sont pas
modélisés ; la charge n'est pas exposée dans l'Observation. Une cible
volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à
contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le
JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of
+106 -4
View File
@@ -686,6 +686,27 @@ class PendingCast:
hp_at_cast: int 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 @dataclass
class CombatantState: class CombatantState:
"""Mutable runtime state of one combatant on the grid.""" """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 # model): the spell begun on this combatant's turn, resolved just before
# its NEXT turn begins; None when no casting is in progress. # its NEXT turn begins; None when no casting is in progress.
pending_cast: PendingCast | None = None 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]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -1748,6 +1775,49 @@ class CombatEngine:
metamagic=pending.metamagic, slot_level=pending.slot_level, 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: def _forfeit_pending_cast(self, caster: CombatantState) -> None:
"""PF1e (SRD "Casting Time"): "you must continue the concentration """PF1e (SRD "Casting Time"): "you must continue the concentration
from the current round to just before your turn in the next round 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. # the turn; the value is the battle-over flag.
state.last_action_results = [] state.last_action_results = []
return gate 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) self._apply_fear_aura(state)
state.moved_this_turn = False state.moved_this_turn = False
state.power_attack = False state.power_attack = False
@@ -5990,6 +6063,15 @@ class CombatEngine:
kind="cast_spell", ok=False, reason="no_slot", kind="cast_spell", ok=False, reason="no_slot",
detail=f"level {used_level}", 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 # PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
# must clear their interruption gate before the spell is delivered. # must clear their interruption gate before the spell is delivered.
if self._spell_cast_interrupted( if self._spell_cast_interrupted(
@@ -6047,7 +6129,9 @@ class CombatEngine:
""" """
has_area = any(isinstance(e, AreaEffect) for e in spell.effects) has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
blocked, delivery_crit = self._spell_delivery_blocked( 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: if blocked:
return return
@@ -6100,17 +6184,25 @@ class CombatEngine:
caster: CombatantState, caster: CombatantState,
target: CombatantState | None, target: CombatantState | None,
spell: SpellSpec, spell: SpellSpec,
spell_name: str,
*, *,
has_area: bool, has_area: bool,
attack_penalty: int = 0, attack_penalty: int = 0,
metamagic: tuple[str, ...] = (),
slot_level: int | None = None,
) -> tuple[bool, bool]: ) -> tuple[bool, bool]:
"""Delivery gates: a hostile touch spell must land its melee touch """Delivery gates: a hostile touch spell must land its melee touch
attack (B14) and a hostile ranged touch spell (ray) must land its attack (B14) and a hostile ranged touch spell (ray) must land its
ranged touch attack (cover, concealment and crits included), then ranged touch attack (cover, concealment and crits included), then
spell resistance must be overcome. Returns ``(blocked, crit)``: spell resistance must be overcome. Returns ``(blocked, crit)``:
True blocked = no effect (the slot is already spent); ``crit`` is True True blocked = no effect; ``crit`` is True when a damage-dealing
when a damage-dealing touch delivery was confirmed on a natural 20 touch delivery was confirmed on a natural 20 (the spell's damage
(the spell's damage dice are then doubled).""" 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 crit = False
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects) deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
# PF1e multi-projectile rays (Scorching Ray): each projectile rolls its # PF1e multi-projectile rays (Scorching Ray): each projectile rolls its
@@ -6127,6 +6219,16 @@ class CombatEngine:
attack_penalty=attack_penalty, deals_damage=deals_damage, attack_penalty=attack_penalty, deals_damage=deals_damage,
) )
if not hit: 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 return True, False
if ( if (
target is not None target is not None
+87 -7
View File
@@ -7,12 +7,14 @@ B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
and opposed rolls are not affected"); only Heighten Spell raises the DC and opposed rolls are not affected"); only Heighten Spell raises the DC
to the heightened level (SRD heighten-spell). to the heightened level (SRD heighten-spell).
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
creature requires a melee touch attack (miss = spell wasted); a willing creature requires a melee touch attack (a miss HOLDS the charge per SRD
target (self/ally) needs no roll. "Holding the Charge" - the spell survives to be re-attempted; the slot
is spent at the cast); a willing target (self/ally) needs no roll.
Ranged touch (CRB, Magic, Ranged Touch Attacks): a spell with Ranged touch (CRB, Magic, Ranged Touch Attacks): a spell with
requires_ranged_touch (a ray) must land a ranged touch attack (BAB + Dex requires_ranged_touch (a ray) must land a ranged touch attack (BAB + Dex
vs touch AC, no range increment) on a hostile target; auto-hit spells vs touch AC, no range increment) on a hostile target; a miss expends the
like Magic Missile need no roll. spell (SRD: "ranged touch attacks cannot be held until a later turn");
auto-hit spells like Magic Missile need no roll.
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
a DC 15 + 2 x spell level check; casting while grappled requires a DC 15 + 2 x spell level check; casting while grappled requires
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
@@ -318,10 +320,14 @@ class TestHostileTouchDelivery:
"""B14: a touch spell delivered to an unwilling creature requires a """B14: a touch spell delivered to an unwilling creature requires a
melee touch attack (CRB, Magic — Range: Touch).""" melee touch attack (CRB, Magic — Range: Touch)."""
def test_missed_touch_attack_wastes_the_spell(self) -> None: def test_missed_touch_attack_holds_the_charge(self) -> None:
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10) # Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10)
# When: Zap is cast with a touch attack roll of 5 (total 6) # When: Zap is cast with a touch attack roll of 5 (total 6)
# Then: the spell misses, deals no damage, and the slot is spent. # Then: the spell misses and deals no damage; the slot stays spent
# but the SPELL survives as a held charge (SRD "Holding the
# Charge": "If the attack misses, you are still holding the
# charge" - "you can continue to make touch attacks round after
# round").
caster = _caster() caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll engine = _engine([c, f], [5]) # only the attack roll — no damage roll
@@ -329,6 +335,77 @@ class TestHostileTouchDelivery:
assert f.hp == 100 assert f.hp == 100
assert c.spell_slots_left[1] == 0 assert c.spell_slots_left[1] == 0
assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript) assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript)
assert c.held_charge is not None
assert c.held_charge.spell_name == "Zap"
assert c.held_charge.target_id == "foe"
assert any("holds the charge of Zap" in line for line in engine._transcript)
def test_held_charge_re_attempt_hits_and_delivers(self) -> None:
# Given: a caster holding Zap on an adjacent enemy (missed first roll)
# When: the next-turn re-attempt roll 9 + 1 = 10 meets touch AC 10
# Then: the spell delivers for its 1d6 and the charge is cleared.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5, 9, 4]) # miss, hit, 1d6 damage
engine._cast_spell(c, f, "Zap")
assert c.held_charge is not None
engine._resolve_held_charge(c)
assert f.hp == 100 - 4
assert c.held_charge is None
assert any("re-attempts held charge Zap" in line for line in engine._transcript)
def test_held_charge_re_attempt_miss_still_holds(self) -> None:
# Given: the same held charge
# When: the re-attempt misses too (roll 2 + 1 = 3 < 10)
# Then: the charge persists for a later turn (no damage rolled).
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5, 2])
engine._cast_spell(c, f, "Zap")
engine._resolve_held_charge(c)
assert f.hp == 100
assert c.held_charge is not None
assert c.held_charge.spell_name == "Zap"
def test_held_charge_out_of_reach_waits_without_rolling(self) -> None:
# Given: the held target has left touch range (5 ft)
# When: the re-attempt runs
# Then: no attack roll is consumed and the charge is kept.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5])
engine._cast_spell(c, f, "Zap")
f.pos = (5, 5)
engine._resolve_held_charge(c) # empty rng queue: would raise on a roll
assert c.held_charge is not None
def test_held_charge_dissipates_when_target_dies(self) -> None:
# Given: the held charge's target died in the meantime
# When: the re-attempt runs
# Then: the charge is gone, no roll consumed.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5])
engine._cast_spell(c, f, "Zap")
f.hp = -50
engine._resolve_held_charge(c)
assert c.held_charge is None
assert any("dissipates (target gone)" in line for line in engine._transcript)
def test_casting_another_spell_dissipates_the_charge(self) -> None:
# Given: a caster holding Zap
# When: the caster casts another spell (SRD: "If you cast another
# spell, the touch spell dissipates")
# Then: the charge is gone before the new cast resolves.
caster = _caster(spell_slots={1: 2})
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5, 3, 3]) # touch miss, then Bolt 2d4
engine._cast_spell(c, f, "Zap")
assert c.held_charge is not None
engine._cast_spell(c, f, "Bolt")
assert c.held_charge is None
assert any("dissipates (casting Bolt)" in line for line in engine._transcript)
assert f.hp == 100 - 6
def test_successful_touch_attack_delivers_the_spell(self) -> None: def test_successful_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy # Given: the same enemy
@@ -388,13 +465,16 @@ class TestRangedTouchDelivery:
def test_missed_ranged_touch_attack_wastes_the_spell(self) -> None: def test_missed_ranged_touch_attack_wastes_the_spell(self) -> None:
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Dex 10) # Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Dex 10)
# When: Ray is cast with an attack roll of 5 (total 6) # When: Ray is cast with an attack roll of 5 (total 6)
# Then: the spell misses, deals no damage, and the slot is spent. # Then: the spell misses, deals no damage, and the slot is spent -
# and the spell is NOT held (SRD: "ranged touch attacks cannot be
# held until a later turn").
c = _state(_caster(), "players", (0, 0)) c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1)) f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll engine = _engine([c, f], [5]) # only the attack roll — no damage roll
engine._cast_spell(c, f, "Ray") engine._cast_spell(c, f, "Ray")
assert f.hp == 100 assert f.hp == 100
assert c.spell_slots_left[1] == 0 assert c.spell_slots_left[1] == 0
assert c.held_charge is None
assert any( assert any(
"ranged touch attack vs foe" in line and "MISS" in line "ranged touch attack vs foe" in line and "MISS" in line
for line in engine._transcript for line in engine._transcript