fix(rules): correct metamagic slots, touch-spell delivery, and defensive/grapple casting
This commit is contained in:
+184
-34
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user