Files
pf1e-simulator/tests/test_conformance_spellflow.py
T

736 lines
31 KiB
Python

"""PF1e conformance: spell-casting flow fixes B7 / B14 / B24.
B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
(Empower +2, Maximize +3, Extend +1, Heighten +1 modeled minimum), and
only feats the caster knows can apply. The save DC uses the spell's
BASE level - metamagic does not raise it (SRD empower: "Saving throws
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.
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.
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
both, never to the injured-while-casting check.
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import Action, ActionResult, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
SPELLS_DIR,
ConditionEffect,
DamageEffect,
SpellSpec,
load_spell,
)
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
# touch condition grace — enough to exercise slot levels, DCs and delivery.
_BOLT = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="medium",
save="none",
effects=(DamageEffect(formula=parse_dice("2d4"), types=("force",)),),
)
_ZAP = SpellSpec(
name="Zap",
level=1,
school="evocation",
range="touch",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
)
_GRACE = SpellSpec(
name="Grace",
level=1,
school="abjuration",
range="touch",
save="none",
effects=(
ConditionEffect(
condition_name="dazzled", duration_rounds=2, save_negates=False
),
),
)
_RAY = SpellSpec(
name="Ray",
level=1,
school="evocation",
range="close",
save="none",
requires_ranged_touch=True,
effects=(DamageEffect(formula=parse_dice("1d6"), types=("cold",)),),
)
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE, "Ray": _RAY}
def _caster(
cid: str = "caster",
*,
level: int = 5,
bab: int = 1,
casting_ability: str = "int",
int_score: int = 18,
wis_score: int = 10,
metamagic: list[str] | None = None,
spell_slots: dict[int, int] | None = None,
combat_casting: bool = False,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=12,
int_score=int_score, wis_score=wis_score, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=3, will=3),
attacks=[attack],
casting_ability=casting_ability,
spell_slots=spell_slots if spell_slots is not None else {1: 1, 2: 1, 3: 1, 4: 1},
metamagic_feats=metamagic or [],
features=features,
)
def _foe(
cid: str = "foe",
*,
touch_ac: int = 10,
dex_score: int = 10,
cmb: int | None = None,
melee: bool = True,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club" if melee else "short bow",
kind="melee" if melee else "ranged",
attack_bonus=0,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
range_increment_ft=None if melee else 60,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=dex_score, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=100,
ac=ACProfile(total=12, touch=touch_ac, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
cmb=cmb,
)
def _grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
return Grid.from_spec(spec)
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
return CombatEngine(
ScriptedRng(queue), _grid(), states, spell_registry=_REGISTRY
)
def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
class TestMetamagicPerCast:
"""B7: metamagic is chosen per cast and costs slot levels, but the save
DC stays at the spell's base level - only Heighten Spell raises it
(SRD, Metamagic Feats)."""
def test_empower_costs_slot_two_levels_higher_but_dc_stays_base(self) -> None:
# Given: a caster who knows Empower Spell with a level-3 slot
# When: casting Bolt (lvl 1) with Empower Spell
# Then: the level-3 slot burns but the DC is 10 + 1 + int 4 = 15
# (SRD empower-spell: "Saving throws and opposed rolls are not
# affected" - the higher slot does not raise the DC).
caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3]) # 2d4 -> 6, empowered -> 9
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert c.spell_slots_left[3] == 0
assert c.spell_slots_left[1] == 1
assert f.hp == 100 - 9
assert any("DC15" in line for line in engine._transcript)
assert not any("DC17" in line for line in engine._transcript)
def test_maximize_costs_slot_three_levels_higher_dc_stays_base(self) -> None:
# Given: a caster who knows Maximize Spell
# When: casting Bolt maximized (2d4 max = 8)
# Then: the level-4 slot burns but the DC is 10 + 1 + 4 = 15.
caster = _caster(metamagic=["Maximize Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [1, 1]) # dice overwritten by maximize
engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",))
assert c.spell_slots_left[4] == 0
assert f.hp == 100 - 8
assert any("DC15" in line for line in engine._transcript)
assert not any("DC18" in line for line in engine._transcript)
def test_extend_costs_one_slot_level_doubles_duration_and_dc_stays_base(self) -> None:
# Given: a caster who knows Extend Spell, self-targeting a touch buff
# When: casting Grace (2r) extended
# Then: the level-2 slot burns and the condition lasts 4 rounds.
caster = _caster(metamagic=["Extend Spell"])
c = _state(caster, "players", (0, 0))
engine = _engine([c], []) # self touch: no attack roll, no save
engine._cast_spell(c, c, "Grace", metamagic=("Extend Spell",))
assert c.spell_slots_left[2] == 0
assert c.spell_slots_left[1] == 1
assert c.active_conditions[0].rounds_left == 4
# Extend raises the slot cost but never the DC: a hostile extended
# Bolt still DCs at its base level.
c2 = _state(caster, "players", (0, 0))
f2 = _state(_foe(), "monsters", (3, 0))
engine2 = _engine([c2, f2], [3, 3])
engine2._cast_spell(c2, f2, "Bolt", metamagic=("Extend Spell",))
assert any("DC15" in line for line in engine2._transcript)
assert not any("DC16" in line for line in engine2._transcript)
def test_heighten_spell_raises_the_dc_to_the_heightened_level(self) -> None:
# Given: a caster who knows Heighten Spell (modeled at its minimum
# +1 adjustment - the engine's per-cast API carries feat names only)
# When: casting Bolt (lvl 1) heightened
# Then: the level-2 slot burns AND the DC is 10 + 2 + int 4 = 16
# (SRD heighten-spell: "All effects dependent on spell level (such
# as saving throw DCs [...]) are calculated according to the
# heightened level").
caster = _caster(metamagic=["Heighten Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
engine._cast_spell(c, f, "Bolt", metamagic=("Heighten Spell",))
assert c.spell_slots_left[2] == 0
assert c.spell_slots_left[1] == 1
assert any("DC16" in line for line in engine._transcript)
def test_unknown_metamagic_feat_is_never_applied(self) -> None:
# Given: a caster who knows NO metamagic
# When: the action requests Empower Spell anyway
# Then: the feat is ignored — base slot, base DC, base damage.
caster = _caster(metamagic=[])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert f.hp == 100 - 6
assert any("DC15" in line for line in engine._transcript)
def test_known_but_unrequested_feat_is_not_applied(self) -> None:
# Given: a caster who knows Empower AND Maximize
# When: casting Bolt with no metamagic requested
# Then: the base slot burns, base DC, dice are rolled (not maxed).
caster = _caster(metamagic=["Empower Spell", "Maximize Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
engine._cast_spell(c, f, "Bolt")
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert c.spell_slots_left[4] == 1
assert f.hp == 100 - 6
def test_cast_rejected_without_a_slot_at_the_used_level(self) -> None:
# Given: a caster with only a level-1 slot who knows Empower
# When: an empowered cast needs a level-3 slot
# Then: the cast is rejected (no_slot), nothing is spent or rolled.
caster = _caster(metamagic=["Empower Spell"], spell_slots={1: 1})
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [])
result = engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert isinstance(result, ActionResult)
assert result.ok is False
assert result.reason == "no_slot"
assert c.spell_slots_left[1] == 1
assert f.hp == 100
assert any("NO SPELL SLOT (level 3)" in line for line in engine._transcript)
def test_action_metamagic_field_threads_through_execution(self) -> None:
# Given: a caster with slots who knows Empower
# When: the cast is issued as an Action carrying metamagic
# Then: _execute dispatches the empowered cast (level-3 slot burns).
caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
metamagic=("Empower Spell",),
),
)
assert result.ok is True
assert c.spell_slots_left[3] == 0
assert f.hp == 100 - 9
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:
# 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.
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
engine._cast_spell(c, f, "Zap")
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)
def test_successful_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy
# When: the touch attack roll is 10 (total 11 >= 10)
# Then: the zap lands for its 1d6.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [10, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 4
assert any("HIT" in line for line in engine._transcript)
def test_touch_attack_strips_dex_when_target_denies_dex(self) -> None:
# Given: a flat-footed enemy (Dex 14, touch 13 -> effective AC 11)
# When: the caster (bonus +1) rolls a 9 (total 10)
# Then: the attack misses the reduced touch AC.
caster = _caster()
foe = _state(_foe(touch_ac=13, dex_score=14, melee=False), "monsters", (0, 1))
foe.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=1,
source="test",
)
)
c = _state(caster, "players", (0, 0))
engine = _engine([c, foe], [9])
engine._cast_spell(c, foe, "Zap")
assert foe.hp == 100
assert any("AC 11" in line for line in engine._transcript)
def test_touch_spell_on_ally_needs_no_attack_roll(self) -> None:
# Given: an ally on the caster's side
# When: a touch spell is cast on them with an EMPTY die queue
# Then: no attack roll is consumed and the effect lands.
caster = _caster()
ally = _state(_foe(), "players", (0, 1))
c = _state(caster, "players", (0, 0))
engine = _engine([c, ally], [])
engine._cast_spell(c, ally, "Grace")
assert ally.active_conditions[0].condition.name == "dazzled"
def test_self_touch_spell_needs_no_attack_roll(self) -> None:
# Given: a caster targeting self
# When: a touch spell is cast with an EMPTY die queue
# Then: it lands without any attack roll (unchanged behaviour).
c = _state(_caster(), "players", (0, 0))
engine = _engine([c], [])
engine._cast_spell(c, c, "Grace")
assert c.active_conditions[0].condition.name == "dazzled"
class TestRangedTouchDelivery:
"""Ranged touch attacks (CRB, Magic): a ray spell requires a ranged touch
attack (BAB + Dex vs touch AC); auto-hit spells like Magic Missile do not."""
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.
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 any(
"ranged touch attack vs foe" in line and "MISS" in line
for line in engine._transcript
)
def test_successful_ranged_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy
# When: the ranged touch attack roll is 10 (total 11 >= 10)
# Then: the ray lands for its 1d6.
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [10, 4])
engine._cast_spell(c, f, "Ray")
assert f.hp == 100 - 4
assert any(
"ranged touch attack vs foe" in line and "HIT" in line
for line in engine._transcript
)
def test_ranged_touch_on_ally_needs_no_attack_roll(self) -> None:
# Given: an ally on the caster's side
# When: a ray is cast on them with an EMPTY die queue
# Then: no attack roll is consumed and the effect lands (willing).
c = _state(_caster(), "players", (0, 0))
ally = _state(_foe(), "players", (0, 1))
engine = _engine([c, ally], [4])
engine._cast_spell(c, ally, "Ray")
assert ally.hp == 100 - 4
assert not any("ranged touch attack" in line for line in engine._transcript)
class TestTouchDeliveryDefense:
"""L3 conformance: a touch/ranged-touch attack is an attack roll, so the
target keeps its cover bonus (CRB "Cover"), concealment can make it miss
(CRB "Concealment"), and a damage-dealing touch spell threatens on a
natural 20 and deals double damage on confirmation (CRB "Cast a Spell —
Touch Attacks", "Range — Touch")."""
def _wall_engine(
self, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
"W": TerrainType(type="ruin", move_cost=None, cover_bonus=2),
}
grid = Grid.from_spec(
MapSpec(
name="cover",
terrain=("..W..", "..W..", ".#...", "....."),
legend=legend,
)
)
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (1, 4))
return (
CombatEngine(ScriptedRng(queue), grid, [c, f], spell_registry=_REGISTRY),
c,
f,
)
def test_ranged_touch_terrain_cover_raises_touch_ac(self) -> None:
# Given: ruins grazing the firing line (+2 partial cover, LoE intact)
# When: Ray is cast with roll 10 (total 11)
# Then: the attack is resolved against touch AC 10 + 2 = 12 and misses.
engine, c, f = self._wall_engine([10])
engine._cast_spell(c, f, "Ray")
assert f.hp == 100
assert any(
"ranged touch attack vs foe" in line and "AC 12" in line
for line in engine._transcript
)
def test_ranged_touch_soft_cover_from_interposed_ally(self) -> None:
# Given: an ally standing between the caster and the target
# When: Ray is cast with roll 5 (total 6)
# Then: the target gains soft cover +4 — resolved vs AC 14, miss.
c = _state(_caster(), "players", (0, 0))
ally = _state(_foe(), "players", (0, 1))
f = _state(_foe(melee=False), "monsters", (0, 2))
engine = _engine([c, ally, f], [5])
engine._cast_spell(c, f, "Ray")
assert f.hp == 100
assert any(
"ranged touch attack vs foe" in line and "AC 14" in line
for line in engine._transcript
)
def test_concealment_makes_a_landed_touch_spell_miss(self) -> None:
# Given: a blurred target (20% concealment); the touch roll hits
# When: the concealment d100 comes up under the miss chance
# Then: the spell is lost (slot burned, no effect).
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
f.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["blurred"],
rounds_left=5,
source="test",
)
)
engine = _engine([c, f], [15, 10])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"concealment vs foe" in line and "spell lost" in line
for line in engine._transcript
)
def test_concealment_high_d100_delivers_the_touch_spell(self) -> None:
# Given: the same blurred target, hit roll and a high concealment die
# When: Zap resolves
# Then: the spell lands for its normal 1d6 (4).
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
f.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["blurred"],
rounds_left=5,
source="test",
)
)
engine = _engine([c, f], [15, 90, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 4
assert any(
"concealment vs foe" in line and "spell delivered" in line
for line in engine._transcript
)
def test_touch_nat20_confirmed_crit_doubles_damage(self) -> None:
# Given: a damage-dealing touch spell (Zap 1d6), natural 20 threatens
# When: the confirm roll (10+1 >= 10) succeeds
# Then: the spell deals double damage (4 -> 8).
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [20, 10, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 8
assert any(
"touch attack crit confirm" in line and "-> CRIT" in line
for line in engine._transcript
)
def test_touch_crit_failed_confirmation_deals_normal_damage(self) -> None:
# Given: natural 20 threatens
# When: the confirm roll is a natural 1 (auto-fail)
# Then: the spell still hits for normal damage (4).
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [20, 1, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 4
assert any(
"touch attack crit confirm" in line and "-> no crit" in line
for line in engine._transcript
)
def test_ranged_touch_nat20_crit_doubles_damage(self) -> None:
# Given: a damage ray (1d6), natural 20 threatens
# When: the confirm succeeds
# Then: double damage (4 -> 8).
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [20, 10, 4])
engine._cast_spell(c, f, "Ray")
assert f.hp == 100 - 8
assert any(
"ranged touch attack crit confirm" in line and "-> CRIT" in line
for line in engine._transcript
)
def test_non_damage_touch_spell_does_not_threaten(self) -> None:
# Given: a condition-only touch spell (Grace) cast on a natural 20
# When: the delivery resolves with only the attack die in the queue
# Then: no confirmation roll is consumed (CRB: criticals only for
# spells that deal damage) and the condition still lands.
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [20])
engine._cast_spell(c, f, "Grace")
assert any("dazzled" in line for line in engine._transcript)
assert not any("crit confirm" in line for line in engine._transcript)
def test_magic_missile_still_auto_hits(self) -> None:
# Given: the real Magic Missile spell (no ranged touch requirement)
# When: cast with only damage rolls in the queue (a level-5 caster
# fires the SRD three missiles, each 1d4+1)
# Then: no attack roll is consumed — every missile always hits.
missile = load_spell(SPELLS_DIR / "magic_missile.json")
assert missile.requires_ranged_touch is False
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = CombatEngine(
ScriptedRng([3, 3, 3]), # 3 missiles, each 1d4+1 -> 4; no attack roll
_grid(),
[c, f],
spell_registry={"Magic Missile": missile},
)
engine._cast_spell(c, f, "Magic Missile")
assert f.hp == 100 - 12
assert not any("ranged touch attack" in line for line in engine._transcript)
def test_requires_ranged_touch_parses_from_spell_json(self) -> None:
# Given: the shipped spell data
# When: ray spells and non-rays are loaded
# Then: the flag reflects the SRD delivery (rays True, others False).
assert load_spell(SPELLS_DIR / "ray_of_frost.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "scorching_ray.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "acid_arrow.json").requires_ranged_touch
assert load_spell(SPELLS_DIR / "fireball.json").requires_ranged_touch is False
assert SpellSpec(name="X", level=0, school="evocation").requires_ranged_touch is False
class TestDefensiveAndGrappleCasting:
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
Concentration). Combat Casting's +4 applies to both."""
def test_defensive_cast_skips_aoo_and_lands_on_passed_check(self) -> None:
# Given: a threatened caster (adjacent armed enemy)
# When: Bolt is cast defensively (DC 17) with roll 8 + lvl 5 + int 4
# Then: no AoO is provoked and the spell resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3]) # concentration, then 2d4
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_defensive_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same threatened caster
# When: the defensive check fails (roll 1 + 5 + 4 = 10 < 17)
# Then: the spell is lost, the slot spent, no damage roll happens.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [1])
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting defensively: concentration failed (DC 17), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_defensive_check(self) -> None:
# Given: a wis-based caster (mod 0) with and without Combat Casting
# When: the defensive check roll is 8 (DC 17)
# Then: with the feat 8+5+4 = 17 passes; without, 13 fails.
plain = _caster(casting_ability="wis", int_score=10, combat_casting=False)
pc = _state(plain, "players", (0, 0))
pf = _state(_foe(), "monsters", (0, 1))
engine_fail = _engine([pc, pf], [8])
engine_fail._cast_spell(pc, pf, "Bolt", cast_defensively=True)
assert pf.hp == 100
feat = _caster(casting_ability="wis", int_score=10, combat_casting=True)
fc = _state(feat, "players", (0, 0))
ff = _state(_foe(), "monsters", (0, 1))
engine_pass = _engine([fc, ff], [8, 3, 3])
engine_pass._cast_spell(fc, ff, "Bolt", cast_defensively=True)
assert ff.hp == 100 - 6
def test_grappled_caster_must_pass_the_grapple_check(self) -> None:
# Given: a caster grappled by an enemy with CMB 7 (DC 10+7+1 = 18)
# When: the check roll is 9 (9 + lvl 5 + int 4 = 18)
# Then: the cast succeeds — and no AoO path is taken.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_grappled_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same grappled caster
# When: the check roll is 8 (17 < 18)
# Then: the spell is lost with the grapple fizzle log.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [8])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting while grappled: concentration failed (DC 18), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_grapple_check(self) -> None:
# Given: a grappled wis-based caster (mod 0) with Combat Casting
# When: the check roll is 9 (9 + 5 + 4 = 18 >= DC 18)
# Then: the cast lands.
caster = _caster(casting_ability="wis", int_score=10, combat_casting=True)
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100 - 6
def test_normal_cast_still_provokes_the_aoo(self) -> None:
# Given: a threatened caster NOT casting defensively
# When: Bolt is cast (enemy AoO roll 2 misses AC 12)
# Then: the AoO is provoked and the spell still resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [2, 3, 3]) # AoO d20, then 2d4
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used.get("foe") == 1
assert f.hp == 100 - 6
def test_action_cast_defensively_threads_through_execution(self) -> None:
# Given: a threatened caster
# When: the cast is issued as an Action with cast_defensively
# Then: _execute dispatches the defensive cast (no AoO, check made).
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
cast_defensively=True,
),
)
assert result.ok is True
assert engine._aoo_used == {}
assert f.hp == 100 - 6