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
+35 -10
View File
@@ -248,10 +248,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
}
# PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
# level + the feat's adjustment. B7: applied per cast, never blanket-on.
# Heighten Spell's RAW adjustment is variable (the caster picks the
# heightened level); the engine's per-cast API carries feat names only, so
# it is modeled at its minimum +1 (documented simplification).
_METAMAGIC_SLOT_ADJUST: dict[str, int] = {
"Empower Spell": 2,
"Maximize Spell": 3,
"Extend Spell": 1,
"Heighten Spell": 1,
}
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
@@ -664,8 +668,10 @@ class PendingCast:
your turn in the round after you began casting the spell. You then act
normally after the spell is completed." Everything the delivery still
needs is frozen at the start of the casting (the slot is already
spent): ``slot_level`` carries the metamagic-inflated level (B7) the
save DC uses, ``hp_at_cast`` is the caster's hp when the casting began
spent): ``slot_level`` carries the metamagic-inflated level (B7) that
drives the concentration checks and the Heighten Spell DC (other
metamagic does not raise the DC - see _spell_dc), ``hp_at_cast`` is the
caster's hp when the casting began
— the net hp lost before resolution is the interruption damage that
forces the concentration check (CRB "Concentration": injured while
casting, DC 10 + damage + spell level).
@@ -5867,15 +5873,30 @@ class CombatEngine:
return (ability_score - 10) // 2
def _spell_dc(
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
self,
caster: CombatantState,
spell: SpellSpec,
*,
slot_level: int | None = None,
metamagic: tuple[str, ...] = (),
) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier.
PF1e (CRB, B7): a metamagic spell uses the level of the slot cast
from, so ``slot_level`` overrides the spell's base level when given.
PF1e (SRD, Metamagic Feats): a metamagic spell occupies a higher
slot but its save DC uses the spell's BASE level - "An empowered
spell uses up a spell slot two levels higher than the spell's
actual level" while "Saving throws and opposed rolls are not
affected" (feats/empower-spell.md). The one exception is Heighten
Spell: "Unlike other metamagic feats, Heighten Spell actually
increases the effective level of the spell [...] All effects
dependent on spell level (such as saving throw DCs [...]) are
calculated according to the heightened level"
(feats/heighten-spell.md) - so ``slot_level`` (the level the spell
is cast at) feeds the DC only when Heighten Spell is applied.
"""
level = spell.level if slot_level is None else slot_level
return 10 + level + self._casting_ability_mod(caster)
if "Heighten Spell" in metamagic and slot_level is not None:
return 10 + slot_level + self._casting_ability_mod(caster)
return 10 + spell.level + self._casting_ability_mod(caster)
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
"""One per-spell dict for the Observation, enriched from the registry."""
@@ -6344,14 +6365,18 @@ class CombatEngine:
``metamagic`` holds the feats chosen for THIS cast (already filtered
to known ones, B7); ``slot_level`` is the spell's level in the slot
used — the save DC is computed from it (PF1e CRB: a metamagic spell
uses the DC of the higher slot level). ``spell_crit`` is a confirmed
used - it drives the slot cost and the concentration checks, but NOT
the save DC: metamagic other than Heighten Spell does not raise the
DC (SRD feats/empower-spell.md: "Saving throws and opposed rolls are
not affected"; feats/heighten-spell.md: only Heighten Spell
"increases the effective level of the spell", and its DC is computed
from the heightened level). ``spell_crit`` is a confirmed
touch-delivery critical: the spell's damage dice are doubled (CRB
"Range — Touch": "deals double damage on a successful critical hit").
``attack_penalty`` (Spell Combat) folds into each projectile's ranged
touch attack for multi-projectile ray spells.
"""
dc = self._spell_dc(caster, spell, slot_level=slot_level)
dc = self._spell_dc(caster, spell, slot_level=slot_level, metamagic=metamagic)
save_result = (
self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)