fix(rules): correct metamagic slots, touch-spell delivery, and defensive/grapple casting

This commit is contained in:
2026-09-11 00:06:06 +02:00
parent 9aee4f6f6b
commit 890b7959d4
4 changed files with 691 additions and 52 deletions
+184 -34
View File
@@ -202,6 +202,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
"reposition": "Improved Reposition",
"drag": "Improved Drag",
}
# PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
# level + the feat's adjustment. B7: applied per cast, never blanket-on.
_METAMAGIC_SLOT_ADJUST: dict[str, int] = {
"Empower Spell": 2,
"Maximize Spell": 3,
"Extend Spell": 1,
}
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
# Strength skills the conformance adjudication groups with the Dex skills.
@@ -677,7 +685,7 @@ class Action:
outside the map or without line of effect from the caster is rejected
(``invalid_destination``); with ``target_pos`` and no resolved target the
spell is cast on the point and every creature in the area rolls its own
save. ``aid_another`` is a standard action helping a resolved ally: the
save. ``aid_another`` is a standard action helping a resolved ally: the
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
against AC 10; on success the ally gains +2 on its next attack roll
(``aid_mode`` "attack" or None) or +2 AC against the next attack
@@ -686,6 +694,15 @@ class Action:
reusing the move machinery (step-by-step, AoO-provoking); it sets
``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC.
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
apply; the spell then occupies a slot of base level + adjustment
(Empower +2, Maximize +3, Extend +1) and its save DC uses that slot
level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
but must pass a concentration check (DC 15 + 2 x spell level, +4 with
Combat Casting) or the spell fizzles.
"""
kind: Literal[
@@ -713,6 +730,10 @@ class Action:
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
# B7: metamagic feats to apply to THIS cast (only known ones apply)
metamagic: tuple[str, ...] = ()
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles
cast_defensively: bool = False
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
@@ -2788,7 +2809,7 @@ class CombatEngine:
"""
dc = self._concentration_dc(caster, spell_level, damage_taken)
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
has_combat_casting = self._has_feat(caster, "Combat Casting")
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
roll = self._rng.d20()
total = (
@@ -3306,7 +3327,8 @@ class CombatEngine:
kind = action.kind
if kind == "cast_spell":
return self._execute_cast_spell(
state, target, action.spell_name, target_pos=action.target_pos
state, target, action.spell_name, target_pos=action.target_pos,
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
)
if kind == "maneuver":
self._execute_maneuver(
@@ -3390,7 +3412,8 @@ class CombatEngine:
state.immediate_used = True
if action.spell_name is not None:
self._execute_cast_spell(
state, target, action.spell_name, target_pos=action.target_pos
state, target, action.spell_name, target_pos=action.target_pos,
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
)
elif action.maneuver is not None:
self._execute_maneuver(state, target, action.maneuver)
@@ -3507,10 +3530,14 @@ class CombatEngine:
spell_name: str | None,
*,
target_pos: tuple[int, int] | None = None,
metamagic: tuple[str, ...] = (),
cast_defensively: bool = False,
) -> ActionResult | None:
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
validated first — an off-map point or one without line of effect
from the caster is rejected without casting."""
from the caster is rejected without casting. ``metamagic`` (B7)
selects the feats applied to THIS cast; ``cast_defensively`` (B24)
trades the casting AoO for a concentration check."""
if self.has_condition_flag(state, "cant_attack"):
# PF1e (CRB): a nauseated creature cannot cast spells or
# concentrate; the cant_attack flag blocks the cast entirely.
@@ -3525,8 +3552,10 @@ class CombatEngine:
return rejection
if spell_name is None:
return None
self._cast_spell(state, target, spell_name, center_pos=target_pos)
return None
return self._cast_spell(
state, target, spell_name, center_pos=target_pos,
metamagic=metamagic, cast_defensively=cast_defensively,
)
def _point_target_rejection(
self, state: CombatantState, target_pos: tuple[int, int]
@@ -4361,9 +4390,16 @@ class CombatEngine:
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
return (ability_score - 10) // 2
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier."""
return 10 + spell.level + self._casting_ability_mod(caster)
def _spell_dc(
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
) -> 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.
"""
level = spell.level if slot_level is None else slot_level
return 10 + 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."""
@@ -4402,7 +4438,9 @@ class CombatEngine:
spell_name: str,
*,
center_pos: tuple[int, int] | None = None,
) -> None:
metamagic: tuple[str, ...] = (),
cast_defensively: bool = False,
) -> ActionResult | None:
"""Cast ``spell_name``.
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell
@@ -4410,7 +4448,12 @@ class CombatEngine:
spells carrying an area; every other effect still resolves on the
target. A cast with no resolved target requires ``center_pos`` and an
area effect — the primary save is skipped and each creature in the
area rolls its own.
area rolls its own. ``metamagic`` (B7) lists the feats applied to
THIS cast (only feats the caster knows apply, and the spell then
occupies — and must have — a slot of base level + adjustment).
``cast_defensively`` (B24) skips the casting AoO for a concentration
check. Returns an explicit rejection (``no_slot``) when the required
slot is unavailable; None means the cast proceeded through the gates.
"""
spell = self._spell_registry.get(spell_name)
if spell is None:
@@ -4418,31 +4461,128 @@ class CombatEngine:
f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> UNKNOWN SPELL"
)
return
return None
# PF1e (CRB, Metamagic Feats): metamagic is chosen per cast and only
# the feats the caster actually knows can be applied (B7).
known = set(caster.combatant.metamagic_feats)
applied = tuple(f for f in metamagic if f in known)
used_level = spell.level + sum(
_METAMAGIC_SLOT_ADJUST.get(f, 0) for f in applied
)
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
if self._spell_cast_rejected(
caster, target, spell, spell_name, center_pos, has_area=has_area
):
return
return None
# PF1e (CRB): a caster cannot cast without an available slot of the
# spell's level; cantrips (level 0) are cast freely. The slot is spent
# up-front — a later failed concentration check still burns it.
if spell.level > 0 and not caster.spend_spell_slot(spell.level):
# spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
# freely. The slot is spent up-front — a later failed concentration
# check or missed touch attack still burns it.
if used_level > 0 and not caster.spend_spell_slot(used_level):
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> NO SPELL SLOT (level {spell.level})"
f"cast {spell_name} -> NO SPELL SLOT (level {used_level})"
)
return
# Check for AoO from casting in threatened square (before resolving spell)
if self._check_casting_aoo(caster, spell.level):
return
return ActionResult(
kind="cast_spell", ok=False, reason="no_slot",
detail=f"level {used_level}",
)
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
# must clear their interruption gate before the spell is delivered.
if self._spell_cast_interrupted(
caster, used_level, cast_defensively=cast_defensively
):
return None
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
# and spell resistance can still turn the spell away.
if self._spell_delivery_blocked(caster, target, spell, has_area=has_area):
return None
self._apply_and_log_cast(
caster, target, spell, spell_name, center_pos,
metamagic=applied, slot_level=used_level,
)
return None
def _spell_cast_interrupted(
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool
) -> bool:
"""Pre-delivery interruption gate (B24).
PF1e (CRB, Concentration): while grappled the caster must pass
DC 10 + grappler's CMB + spell level to cast at all; casting
defensively forgoes the provocation but requires DC 15 + 2x spell
level (Combat Casting's +4 applies to both); otherwise casting in a
threatened square provokes the AoO (with the injured-casting check).
True = the spell is lost (the slot is already spent).
"""
if caster.grappled_with is not None or cast_defensively:
if not self._check_concentration(caster, spell_level, 0):
dc = self._concentration_dc(caster, spell_level, 0)
situation = (
"while grappled" if caster.grappled_with is not None else "defensively"
)
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"casting {situation}: concentration failed (DC {dc}), spell lost"
)
return True
return False
return self._check_casting_aoo(caster, spell_level)
def _spell_delivery_blocked(
self,
caster: CombatantState,
target: CombatantState | None,
spell: SpellSpec,
*,
has_area: bool,
) -> bool:
"""Delivery gates: a hostile touch spell must land its melee touch
attack (B14), then spell resistance must be overcome. True = no
effect (the slot is already spent)."""
if (
target is not None
and spell.range == "touch"
and target.side != caster.side
and not self._spell_touch_attack_hits(caster, target)
):
return True
return (
target is not None
and not has_area
and not self._check_spell_resistance(caster, target)
):
return
self._apply_and_log_cast(caster, target, spell, spell_name, center_pos)
)
def _spell_touch_attack_hits(
self, caster: CombatantState, target: CombatantState
) -> bool:
"""Melee touch attack to deliver a touch-range spell (B14).
PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch
AC. Engine simplification (documented): the bonus is BAB + Strength
modifier — Weapon Finesse and other situational touch bonuses are
not modeled; natural 20 hits and natural 1 misses per the engine's
attack-roll convention. The touch AC reuses the resolve_attack
denies-Dex path (flat-footed/running target, invisible attacker).
"""
roll = self._rng.d20()
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._has_condition_named(caster, "invisible")
)
if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
else:
ac = target.combatant.ac.touch
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
self._log(
f"round {self._current_round} {caster.combatant.id}: touch attack vs "
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
f"-> {'HIT' if hit else 'MISS'}"
)
return hit
def _spell_cast_rejected(
self,
@@ -4512,9 +4652,18 @@ class CombatEngine:
spell: SpellSpec,
spell_name: str,
center_pos: tuple[int, int] | None,
*,
metamagic: tuple[str, ...] = (),
slot_level: int | None = None,
) -> None:
"""Resolve save, apply effects and log a spell cast (post-gates)."""
dc = self._spell_dc(caster, spell)
"""Resolve save, apply effects and log a spell cast (post-gates).
``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).
"""
dc = self._spell_dc(caster, spell, slot_level=slot_level)
save_result = (
self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
@@ -4526,15 +4675,14 @@ class CombatEngine:
target_live = (
self._stats[target.combatant.id] if target is not None else caster_live
)
# Apply metamagic feats to spell effects
metamagic_feats = caster.combatant.metamagic_feats
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
# Apply only the metamagic feats chosen for this cast (B7)
metamagic_effects = self._apply_metamagic(spell, metamagic)
hp_before = target.hp if target is not None else caster.hp
parts = self._apply_spell_effects_from_list(
metamagic_effects, target, save_result, caster_live, target_live,
caster=caster, spell_name=spell.name,
empower="Empower Spell" in metamagic_feats,
maximize="Maximize Spell" in metamagic_feats,
empower="Empower Spell" in metamagic,
maximize="Maximize Spell" in metamagic,
dc=dc, center_pos=center_pos, save_type=spell.save,
)
save_str = _save_label(save_result)
@@ -4856,9 +5004,9 @@ class CombatEngine:
return save_result.success
def _apply_metamagic(
self, spell: SpellSpec, metamagic_feats: list[str]
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
) -> tuple[SpellEffect, ...]:
"""Apply metamagic feats to spell effects on the fly."""
"""Apply the feats chosen for THIS cast to the spell's effects (B7)."""
if not metamagic_feats:
return spell.effects
has_extend = "Extend Spell" in metamagic_feats
@@ -5582,6 +5730,8 @@ def _ready_payload(action: Action) -> Action:
target_id=action.target_id,
spell_name=action.spell_name,
target_pos=action.target_pos,
metamagic=action.metamagic,
cast_defensively=action.cast_defensively,
)
if action.maneuver is not None:
return Action(