fix(rules): metamagic no longer raises the save DC - only Heighten Spell does (SRD empower-spell: saving throws not affected; heighten-spell: DCs calculated at the heightened level, modeled at its minimum +1 slot adjustment)

This commit is contained in:
2026-09-19 11:33:10 +02:00
parent b74baf1aa9
commit 81a09c88f0
3 changed files with 88 additions and 23 deletions
+43 -11
View File
@@ -1,8 +1,11 @@
"""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.
(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.
@@ -182,13 +185,16 @@ def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantSt
class TestMetamagicPerCast:
"""B7: metamagic is chosen per cast, costs slot levels, and the DC uses
the slot level (CRB, Metamagic Feats)."""
"""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_and_dc_uses_it(self) -> None:
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 and the DC is 10 + 3 + int 4 = 17.
# 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
@@ -196,21 +202,23 @@ class TestMetamagicPerCast:
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)
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(self) -> None:
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 and the DC is 10 + 4 + 4 = 18.
# 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("DC18" in line for line in engine._transcript)
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_and_doubles_duration(self) -> None:
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.
@@ -221,6 +229,30 @@ class TestMetamagicPerCast:
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