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