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
+10 -2
View File
@@ -966,7 +966,8 @@ Règles modélisées :
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
`ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du `ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
sort (niveau d'emplacement effectif en cas de métamagie, voir ci-dessous) + sort (niveau de base du sort — la métamagie ne le majore PAS, sauf
Heighten Spell, voir ci-dessous) +
mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
(`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB (`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
@@ -975,7 +976,14 @@ Règles modélisées :
dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
occupe alors un emplacement de niveau de base + ajustement (Empower +2, occupe alors un emplacement de niveau de base + ajustement (Empower +2,
Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans Maximize +3, Extend +1) mais **son DD reste calculé au niveau de base du
sort** (SRD `feats/empower-spell.md` : « Saving throws and opposed rolls
are not affected ») — seule l'exception SRD Heighten Spell majore le DD au
niveau effectif majoré (SRD `feats/heighten-spell.md` : « All effects
dependent on spell level (such as saving throw DCs [...]) are calculated
according to the heightened level »), modélisée à son ajustement minimal
+1 (la signature d'action ne porte pas le niveau choisi — simplification
documentée) ; sans
emplacement au niveau requis, l'incantation est refusée (`no_slot`). Avec emplacement au niveau requis, l'incantation est refusée (`no_slot`). Avec
`metamagic` vide (défaut), le comportement est inchangé. Incanter dans une `metamagic` vide (défaut), le comportement est inchangé. Incanter dans une
case menacée provoque des AoO. Incantation défensive (conformance B24, CRB case menacée provoque des AoO. Incantation défensive (conformance B24, CRB
+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 # PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
# level + the feat's adjustment. B7: applied per cast, never blanket-on. # 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] = { _METAMAGIC_SLOT_ADJUST: dict[str, int] = {
"Empower Spell": 2, "Empower Spell": 2,
"Maximize Spell": 3, "Maximize Spell": 3,
"Extend Spell": 1, "Extend Spell": 1,
"Heighten Spell": 1,
} }
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only) _COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills" # 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 your turn in the round after you began casting the spell. You then act
normally after the spell is completed." Everything the delivery still normally after the spell is completed." Everything the delivery still
needs is frozen at the start of the casting (the slot is already needs is frozen at the start of the casting (the slot is already
spent): ``slot_level`` carries the metamagic-inflated level (B7) the spent): ``slot_level`` carries the metamagic-inflated level (B7) that
save DC uses, ``hp_at_cast`` is the caster's hp when the casting began 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 — the net hp lost before resolution is the interruption damage that
forces the concentration check (CRB "Concentration": injured while forces the concentration check (CRB "Concentration": injured while
casting, DC 10 + damage + spell level). casting, DC 10 + damage + spell level).
@@ -5867,15 +5873,30 @@ class CombatEngine:
return (ability_score - 10) // 2 return (ability_score - 10) // 2
def _spell_dc( 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: ) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier. """Spell save DC: 10 + spell level + casting ability modifier.
PF1e (CRB, B7): a metamagic spell uses the level of the slot cast PF1e (SRD, Metamagic Feats): a metamagic spell occupies a higher
from, so ``slot_level`` overrides the spell's base level when given. 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 if "Heighten Spell" in metamagic and slot_level is not None:
return 10 + level + self._casting_ability_mod(caster) 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]: def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
"""One per-spell dict for the Observation, enriched from the registry.""" """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 ``metamagic`` holds the feats chosen for THIS cast (already filtered
to known ones, B7); ``slot_level`` is the spell's level in the slot 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 used - it drives the slot cost and the concentration checks, but NOT
uses the DC of the higher slot level). ``spell_crit`` is a confirmed 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 touch-delivery critical: the spell's damage dice are doubled (CRB
"Range — Touch": "deals double damage on a successful critical hit"). "Range — Touch": "deals double damage on a successful critical hit").
``attack_penalty`` (Spell Combat) folds into each projectile's ranged ``attack_penalty`` (Spell Combat) folds into each projectile's ranged
touch attack for multi-projectile ray spells. 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 = ( save_result = (
self.resolve_save( self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
+43 -11
View File
@@ -1,8 +1,11 @@
"""PF1e conformance: spell-casting flow fixes B7 / B14 / B24. """PF1e conformance: spell-casting flow fixes B7 / B14 / B24.
B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels 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 (Empower +2, Maximize +3, Extend +1, Heighten +1 modeled minimum), and
only feats the caster knows can apply. 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 B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
creature requires a melee touch attack (miss = spell wasted); a willing creature requires a melee touch attack (miss = spell wasted); a willing
target (self/ally) needs no roll. target (self/ally) needs no roll.
@@ -182,13 +185,16 @@ def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantSt
class TestMetamagicPerCast: class TestMetamagicPerCast:
"""B7: metamagic is chosen per cast, costs slot levels, and the DC uses """B7: metamagic is chosen per cast and costs slot levels, but the save
the slot level (CRB, Metamagic Feats).""" 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 # Given: a caster who knows Empower Spell with a level-3 slot
# When: casting Bolt (lvl 1) with Empower Spell # 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"]) caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0)) c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3]) # 2d4 -> 6, empowered -> 9 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[3] == 0
assert c.spell_slots_left[1] == 1 assert c.spell_slots_left[1] == 1
assert f.hp == 100 - 9 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 # Given: a caster who knows Maximize Spell
# When: casting Bolt maximized (2d4 max = 8) # 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"]) caster = _caster(metamagic=["Maximize Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0)) c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [1, 1]) # dice overwritten by maximize engine = _engine([c, f], [1, 1]) # dice overwritten by maximize
engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",)) engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",))
assert c.spell_slots_left[4] == 0 assert c.spell_slots_left[4] == 0
assert f.hp == 100 - 8 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 # Given: a caster who knows Extend Spell, self-targeting a touch buff
# When: casting Grace (2r) extended # When: casting Grace (2r) extended
# Then: the level-2 slot burns and the condition lasts 4 rounds. # 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[2] == 0
assert c.spell_slots_left[1] == 1 assert c.spell_slots_left[1] == 1
assert c.active_conditions[0].rounds_left == 4 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: def test_unknown_metamagic_feat_is_never_applied(self) -> None:
# Given: a caster who knows NO metamagic # Given: a caster who knows NO metamagic