"""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), the DC uses the slot level, and only feats the caster knows can apply. 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. 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 ( ConditionEffect, DamageEffect, SpellSpec, ) # 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 ), ), ) _REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE} 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 _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) return CombatEngine( ScriptedRng(queue), Grid.from_spec(spec), 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, costs slot levels, and the DC uses the slot level (CRB, Metamagic Feats).""" def test_empower_costs_slot_two_levels_higher_and_dc_uses_it(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 and the DC is 10 + 3 + int 4 = 17. 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("DC17" in line for line in engine._transcript) def test_maximize_costs_slot_three_levels_higher(self) -> None: # Given: a caster who knows Maximize Spell # When: casting Bolt maximized (2d4 max = 8) # Then: the level-4 slot burns and the DC is 10 + 4 + 4 = 18. 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("DC18" in line for line in engine._transcript) def test_extend_costs_one_slot_level_and_doubles_duration(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 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 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