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