"""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 (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; 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 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_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 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 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) 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 # 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 - # 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 ) 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