diff --git a/README.md b/README.md index aff2f62..87bd84d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index be72fbc..f148857 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py index 1f6d963..e0bb69b 100644 --- a/tests/test_conformance_spellflow.py +++ b/tests/test_conformance_spellflow.py @@ -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