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
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user