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:
@@ -966,7 +966,8 @@ Règles modélisées :
|
||||
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -975,7 +976,14 @@ Règles modélisées :
|
||||
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
|
||||
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
|
||||
`metamagic` vide (défaut), le comportement est inchangé. Incanter dans une
|
||||
case menacée provoque des AoO. Incantation défensive (conformance B24, CRB
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user