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
+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
to the heightened level (SRD heighten-spell).
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
creature requires a melee touch attack (miss = spell wasted); a willing
target (self/ally) needs no roll.
creature requires a melee touch attack (a miss HOLDS the charge per SRD
"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
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
like Magic Missile need no roll.
vs touch AC, no range increment) on a hostile target; a miss expends the
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
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
@@ -318,10 +320,14 @@ class TestHostileTouchDelivery:
"""B14: a touch spell delivered to an unwilling creature requires a
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)
# 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()
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
@@ -329,6 +335,77 @@ class TestHostileTouchDelivery:
assert f.hp == 100
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 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:
# Given: the same enemy
@@ -388,13 +465,16 @@ class TestRangedTouchDelivery:
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)
# 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))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
engine._cast_spell(c, f, "Ray")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert c.held_charge is None
assert any(
"ranged touch attack vs foe" in line and "MISS" in line
for line in engine._transcript