456 lines
18 KiB
Python
456 lines
18 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), 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
|