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
+40 -12
View File
@@ -402,7 +402,9 @@ Règles modélisées :
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable →
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos` `ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"` hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"`
(U7b) ; course ou aide sans cible **et** sans destination → (U7b) ; incantation sans emplacement disponible au niveau requis — niveau
de base ou majoré par la métamagie (B7) → `ok=False, reason="no_slot"` ;
course ou aide sans cible **et** sans destination →
`missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ; `missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ;
course vers une `destination` hors du budget 4× → course vers une `destination` hors du budget 4× →
`unreachable_destination` (U5e). `unreachable_destination` (U5e).
@@ -579,18 +581,41 @@ Règles modélisées :
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis - Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
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`, aucun disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
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 + mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde sort (niveau d'emplacement effectif en cas de métamagie, 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 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`). Incanter dans une (`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un « Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) : `Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé, dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB) ; occupe alors un emplacement de niveau de base + ajustement (Empower +2,
le don Combat Casting n'accorde son +4 qu'en incantation défensive ou quand Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans
on est agrippé, jamais pour les dégâts subis pendant l'incantation. Un sort ciblé avec sauvegarde fait pleins dégâts 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
« Concentration ») : `Action(kind="cast_spell", cast_defensively=True)`
évite la provocation d'AoO mais impose un jet de concentration DD
`15 + 2×niveau` (+4 avec Combat Casting) ; un échec perd le sort
(emplacement brûlé, journalisé `casting defensively: concentration failed`).
Agrippé (`grappled_with`) : un jet de concentration DD
`10 + CMB de l'adversaire + niveau` (+4 Combat Casting) est requis pour
incanter, défensivement ou non — ce jet remplace la provocation (un
agrippé ne peut pas porter d'AoO). Tout dégât subi pendant l'incantation
(AoO) impose un jet DD `10 + dégâts + niveau` **sans** le bonus Combat
Casting (conformance P1). Jet de concentration = d20 + niveau d'incanteur +
mod de la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution,
CRB). Sorts de contact (conformance B14, CRB « Magic — Range: Touch ») :
délivrer un sort de portée `touch` à une créature **hostile** (camp
différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force
du lanceur contre la CA de contact de la cible (amputée du modificateur de
Dextérité si la cible perd son bonus de Dextérité, même chemin que
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
est brûlé). Simplifications documentées : Weapon Finesse et les autres
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
volontaire (soi ou un allié du même camp) ne demande aucun jet. Un sort ciblé avec sauvegarde fait pleins dégâts
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
@@ -881,6 +906,9 @@ Règles modélisées :
(calcul identique à `resolve_attack`, couvert mou des créatures inclus). (calcul identique à `resolve_attack`, couvert mou des créatures inclus).
Les API de décision exposées (items L1-L8) Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
B7) et `cast_defensively` (incantation défensive, B24) sur l'action
`cast_spell` ;
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
Le kind `activate_power_attack` (action libre) active le don Power Attack Le kind `activate_power_attack` (action libre) active le don Power Attack
(`state.power_attack = True`) si le combattant possède le don ; sans le don, (`state.power_attack = True`) si le combattant possède le don ; sans le don,
+183 -33
View File
@@ -202,6 +202,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
"reposition": "Improved Reposition", "reposition": "Improved Reposition",
"drag": "Improved Drag", "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" # PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the # modifier target — grappled/pinned/blinded -4). Climb and Swim are the
# Strength skills the conformance adjudication groups with the Dex skills. # Strength skills the conformance adjudication groups with the Dex skills.
@@ -686,6 +694,15 @@ class Action:
reusing the move machinery (step-by-step, AoO-provoking); it sets reusing the move machinery (step-by-step, AoO-provoking); it sets
``running``, which lasts until the runner's next turn and denies the ``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC. 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[ kind: Literal[
@@ -713,6 +730,10 @@ class Action:
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step 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 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) 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, # 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) dc = self._concentration_dc(caster, spell_level, damage_taken)
injured_while_casting = damage_taken > 0 and caster.grappled_with is None 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) bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
roll = self._rng.d20() roll = self._rng.d20()
total = ( total = (
@@ -3306,7 +3327,8 @@ class CombatEngine:
kind = action.kind kind = action.kind
if kind == "cast_spell": if kind == "cast_spell":
return self._execute_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": if kind == "maneuver":
self._execute_maneuver( self._execute_maneuver(
@@ -3390,7 +3412,8 @@ class CombatEngine:
state.immediate_used = True state.immediate_used = True
if action.spell_name is not None: if action.spell_name is not None:
self._execute_cast_spell( 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: elif action.maneuver is not None:
self._execute_maneuver(state, target, action.maneuver) self._execute_maneuver(state, target, action.maneuver)
@@ -3507,10 +3530,14 @@ class CombatEngine:
spell_name: str | None, spell_name: str | None,
*, *,
target_pos: tuple[int, int] | None = None, target_pos: tuple[int, int] | None = None,
metamagic: tuple[str, ...] = (),
cast_defensively: bool = False,
) -> ActionResult | None: ) -> ActionResult | None:
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is """Cast the carried spell; an explicit ``target_pos`` (U7b) is
validated first — an off-map point or one without line of effect 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"): if self.has_condition_flag(state, "cant_attack"):
# PF1e (CRB): a nauseated creature cannot cast spells or # PF1e (CRB): a nauseated creature cannot cast spells or
# concentrate; the cant_attack flag blocks the cast entirely. # concentrate; the cant_attack flag blocks the cast entirely.
@@ -3525,8 +3552,10 @@ class CombatEngine:
return rejection return rejection
if spell_name is None: if spell_name is None:
return None return None
self._cast_spell(state, target, spell_name, center_pos=target_pos) return self._cast_spell(
return None state, target, spell_name, center_pos=target_pos,
metamagic=metamagic, cast_defensively=cast_defensively,
)
def _point_target_rejection( def _point_target_rejection(
self, state: CombatantState, target_pos: tuple[int, int] self, state: CombatantState, target_pos: tuple[int, int]
@@ -4361,9 +4390,16 @@ class CombatEngine:
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
return (ability_score - 10) // 2 return (ability_score - 10) // 2
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int: def _spell_dc(
"""Spell save DC: 10 + spell level + casting ability modifier.""" self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
return 10 + spell.level + self._casting_ability_mod(caster) ) -> 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]: 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."""
@@ -4402,7 +4438,9 @@ class CombatEngine:
spell_name: str, spell_name: str,
*, *,
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
) -> None: metamagic: tuple[str, ...] = (),
cast_defensively: bool = False,
) -> ActionResult | None:
"""Cast ``spell_name``. """Cast ``spell_name``.
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell ``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 spells carrying an area; every other effect still resolves on the
target. A cast with no resolved target requires ``center_pos`` and an 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 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) spell = self._spell_registry.get(spell_name)
if spell is None: if spell is None:
@@ -4418,31 +4461,128 @@ class CombatEngine:
f"round {self._current_round} {caster.combatant.id}: " f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> UNKNOWN SPELL" 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) has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
if self._spell_cast_rejected( if self._spell_cast_rejected(
caster, target, spell, spell_name, center_pos, has_area=has_area 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 # 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 # spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
# up-front — a later failed concentration check still burns it. # freely. The slot is spent up-front — a later failed concentration
if spell.level > 0 and not caster.spend_spell_slot(spell.level): # check or missed touch attack still burns it.
if used_level > 0 and not caster.spend_spell_slot(used_level):
self._log( self._log(
f"round {self._current_round} {caster.combatant.id}: " 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 return ActionResult(
# Check for AoO from casting in threatened square (before resolving spell) kind="cast_spell", ok=False, reason="no_slot",
if self._check_casting_aoo(caster, spell.level): detail=f"level {used_level}",
return )
# 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 ( 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 target is not None
and not has_area and not has_area
and not self._check_spell_resistance(caster, target) 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( def _spell_cast_rejected(
self, self,
@@ -4512,9 +4652,18 @@ class CombatEngine:
spell: SpellSpec, spell: SpellSpec,
spell_name: str, spell_name: str,
center_pos: tuple[int, int] | None, center_pos: tuple[int, int] | None,
*,
metamagic: tuple[str, ...] = (),
slot_level: int | None = None,
) -> None: ) -> None:
"""Resolve save, apply effects and log a spell cast (post-gates).""" """Resolve save, apply effects and log a spell cast (post-gates).
dc = self._spell_dc(caster, spell)
``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 = ( 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)
@@ -4526,15 +4675,14 @@ class CombatEngine:
target_live = ( target_live = (
self._stats[target.combatant.id] if target is not None else caster_live self._stats[target.combatant.id] if target is not None else caster_live
) )
# Apply metamagic feats to spell effects # Apply only the metamagic feats chosen for this cast (B7)
metamagic_feats = caster.combatant.metamagic_feats metamagic_effects = self._apply_metamagic(spell, metamagic)
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
hp_before = target.hp if target is not None else caster.hp hp_before = target.hp if target is not None else caster.hp
parts = self._apply_spell_effects_from_list( parts = self._apply_spell_effects_from_list(
metamagic_effects, target, save_result, caster_live, target_live, metamagic_effects, target, save_result, caster_live, target_live,
caster=caster, spell_name=spell.name, caster=caster, spell_name=spell.name,
empower="Empower Spell" in metamagic_feats, empower="Empower Spell" in metamagic,
maximize="Maximize Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic,
dc=dc, center_pos=center_pos, save_type=spell.save, dc=dc, center_pos=center_pos, save_type=spell.save,
) )
save_str = _save_label(save_result) save_str = _save_label(save_result)
@@ -4856,9 +5004,9 @@ class CombatEngine:
return save_result.success return save_result.success
def _apply_metamagic( def _apply_metamagic(
self, spell: SpellSpec, metamagic_feats: list[str] self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
) -> tuple[SpellEffect, ...]: ) -> 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: if not metamagic_feats:
return spell.effects return spell.effects
has_extend = "Extend Spell" in metamagic_feats has_extend = "Extend Spell" in metamagic_feats
@@ -5582,6 +5730,8 @@ def _ready_payload(action: Action) -> Action:
target_id=action.target_id, target_id=action.target_id,
spell_name=action.spell_name, spell_name=action.spell_name,
target_pos=action.target_pos, target_pos=action.target_pos,
metamagic=action.metamagic,
cast_defensively=action.cast_defensively,
) )
if action.maneuver is not None: if action.maneuver is not None:
return Action( return Action(
+455
View File
@@ -0,0 +1,455 @@
"""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.
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.
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
a DC 15 + 2 x spell level check; casting while grappled requires
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
both, never to the injured-while-casting check.
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import Action, ActionResult, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
ConditionEffect,
DamageEffect,
SpellSpec,
)
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
# touch condition grace — enough to exercise slot levels, DCs and delivery.
_BOLT = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="medium",
save="none",
effects=(DamageEffect(formula=parse_dice("2d4"), types=("force",)),),
)
_ZAP = SpellSpec(
name="Zap",
level=1,
school="evocation",
range="touch",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
)
_GRACE = SpellSpec(
name="Grace",
level=1,
school="abjuration",
range="touch",
save="none",
effects=(
ConditionEffect(
condition_name="dazzled", duration_rounds=2, save_negates=False
),
),
)
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE}
def _caster(
cid: str = "caster",
*,
level: int = 5,
bab: int = 1,
casting_ability: str = "int",
int_score: int = 18,
wis_score: int = 10,
metamagic: list[str] | None = None,
spell_slots: dict[int, int] | None = None,
combat_casting: bool = False,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=12,
int_score=int_score, wis_score=wis_score, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=3, will=3),
attacks=[attack],
casting_ability=casting_ability,
spell_slots=spell_slots if spell_slots is not None else {1: 1, 2: 1, 3: 1, 4: 1},
metamagic_feats=metamagic or [],
features=features,
)
def _foe(
cid: str = "foe",
*,
touch_ac: int = 10,
dex_score: int = 10,
cmb: int | None = None,
melee: bool = True,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club" if melee else "short bow",
kind="melee" if melee else "ranged",
attack_bonus=0,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
range_increment_ft=None if melee else 60,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=dex_score, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=100,
ac=ACProfile(total=12, touch=touch_ac, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
cmb=cmb,
)
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
return CombatEngine(
ScriptedRng(queue), Grid.from_spec(spec), states, spell_registry=_REGISTRY
)
def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
class TestMetamagicPerCast:
"""B7: metamagic is chosen per cast, costs slot levels, and the DC uses
the slot level (CRB, Metamagic Feats)."""
def test_empower_costs_slot_two_levels_higher_and_dc_uses_it(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.
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
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
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)
def test_maximize_costs_slot_three_levels_higher(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.
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)
def test_extend_costs_one_slot_level_and_doubles_duration(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.
caster = _caster(metamagic=["Extend Spell"])
c = _state(caster, "players", (0, 0))
engine = _engine([c], []) # self touch: no attack roll, no save
engine._cast_spell(c, c, "Grace", metamagic=("Extend Spell",))
assert c.spell_slots_left[2] == 0
assert c.spell_slots_left[1] == 1
assert c.active_conditions[0].rounds_left == 4
def test_unknown_metamagic_feat_is_never_applied(self) -> None:
# Given: a caster who knows NO metamagic
# When: the action requests Empower Spell anyway
# Then: the feat is ignored — base slot, base DC, base damage.
caster = _caster(metamagic=[])
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=("Empower Spell",))
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert f.hp == 100 - 6
assert any("DC15" in line for line in engine._transcript)
def test_known_but_unrequested_feat_is_not_applied(self) -> None:
# Given: a caster who knows Empower AND Maximize
# When: casting Bolt with no metamagic requested
# Then: the base slot burns, base DC, dice are rolled (not maxed).
caster = _caster(metamagic=["Empower Spell", "Maximize 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")
assert c.spell_slots_left[1] == 0
assert c.spell_slots_left[3] == 1
assert c.spell_slots_left[4] == 1
assert f.hp == 100 - 6
def test_cast_rejected_without_a_slot_at_the_used_level(self) -> None:
# Given: a caster with only a level-1 slot who knows Empower
# When: an empowered cast needs a level-3 slot
# Then: the cast is rejected (no_slot), nothing is spent or rolled.
caster = _caster(metamagic=["Empower Spell"], spell_slots={1: 1})
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [])
result = engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
assert isinstance(result, ActionResult)
assert result.ok is False
assert result.reason == "no_slot"
assert c.spell_slots_left[1] == 1
assert f.hp == 100
assert any("NO SPELL SLOT (level 3)" in line for line in engine._transcript)
def test_action_metamagic_field_threads_through_execution(self) -> None:
# Given: a caster with slots who knows Empower
# When: the cast is issued as an Action carrying metamagic
# Then: _execute dispatches the empowered cast (level-3 slot burns).
caster = _caster(metamagic=["Empower Spell"])
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
engine = _engine([c, f], [3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
metamagic=("Empower Spell",),
),
)
assert result.ok is True
assert c.spell_slots_left[3] == 0
assert f.hp == 100 - 9
class TestHostileTouchDelivery:
"""B14: a touch spell delivered to an unwilling creature requires a
melee touch attack (CRB, Magic — Range: Touch)."""
def test_missed_touch_attack_wastes_the_spell(self) -> None:
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10)
# When: Zap is cast with a touch attack roll of 5 (total 6)
# Then: the spell misses, deals no damage, and the slot is spent.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
engine._cast_spell(c, f, "Zap")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript)
def test_successful_touch_attack_delivers_the_spell(self) -> None:
# Given: the same enemy
# When: the touch attack roll is 10 (total 11 >= 10)
# Then: the zap lands for its 1d6.
caster = _caster()
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
engine = _engine([c, f], [10, 4])
engine._cast_spell(c, f, "Zap")
assert f.hp == 100 - 4
assert any("HIT" in line for line in engine._transcript)
def test_touch_attack_strips_dex_when_target_denies_dex(self) -> None:
# Given: a flat-footed enemy (Dex 14, touch 13 -> effective AC 11)
# When: the caster (bonus +1) rolls a 9 (total 10)
# Then: the attack misses the reduced touch AC.
caster = _caster()
foe = _state(_foe(touch_ac=13, dex_score=14, melee=False), "monsters", (0, 1))
foe.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=1,
source="test",
)
)
c = _state(caster, "players", (0, 0))
engine = _engine([c, foe], [9])
engine._cast_spell(c, foe, "Zap")
assert foe.hp == 100
assert any("AC 11" in line for line in engine._transcript)
def test_touch_spell_on_ally_needs_no_attack_roll(self) -> None:
# Given: an ally on the caster's side
# When: a touch spell is cast on them with an EMPTY die queue
# Then: no attack roll is consumed and the effect lands.
caster = _caster()
ally = _state(_foe(), "players", (0, 1))
c = _state(caster, "players", (0, 0))
engine = _engine([c, ally], [])
engine._cast_spell(c, ally, "Grace")
assert ally.active_conditions[0].condition.name == "dazzled"
def test_self_touch_spell_needs_no_attack_roll(self) -> None:
# Given: a caster targeting self
# When: a touch spell is cast with an EMPTY die queue
# Then: it lands without any attack roll (unchanged behaviour).
c = _state(_caster(), "players", (0, 0))
engine = _engine([c], [])
engine._cast_spell(c, c, "Grace")
assert c.active_conditions[0].condition.name == "dazzled"
class TestDefensiveAndGrappleCasting:
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
Concentration). Combat Casting's +4 applies to both."""
def test_defensive_cast_skips_aoo_and_lands_on_passed_check(self) -> None:
# Given: a threatened caster (adjacent armed enemy)
# When: Bolt is cast defensively (DC 17) with roll 8 + lvl 5 + int 4
# Then: no AoO is provoked and the spell resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3]) # concentration, then 2d4
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_defensive_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same threatened caster
# When: the defensive check fails (roll 1 + 5 + 4 = 10 < 17)
# Then: the spell is lost, the slot spent, no damage roll happens.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [1])
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting defensively: concentration failed (DC 17), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_defensive_check(self) -> None:
# Given: a wis-based caster (mod 0) with and without Combat Casting
# When: the defensive check roll is 8 (DC 17)
# Then: with the feat 8+5+4 = 17 passes; without, 13 fails.
plain = _caster(casting_ability="wis", int_score=10, combat_casting=False)
pc = _state(plain, "players", (0, 0))
pf = _state(_foe(), "monsters", (0, 1))
engine_fail = _engine([pc, pf], [8])
engine_fail._cast_spell(pc, pf, "Bolt", cast_defensively=True)
assert pf.hp == 100
feat = _caster(casting_ability="wis", int_score=10, combat_casting=True)
fc = _state(feat, "players", (0, 0))
ff = _state(_foe(), "monsters", (0, 1))
engine_pass = _engine([fc, ff], [8, 3, 3])
engine_pass._cast_spell(fc, ff, "Bolt", cast_defensively=True)
assert ff.hp == 100 - 6
def test_grappled_caster_must_pass_the_grapple_check(self) -> None:
# Given: a caster grappled by an enemy with CMB 7 (DC 10+7+1 = 18)
# When: the check roll is 9 (9 + lvl 5 + int 4 = 18)
# Then: the cast succeeds — and no AoO path is taken.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used == {}
assert f.hp == 100 - 6
def test_grappled_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
# Given: the same grappled caster
# When: the check roll is 8 (17 < 18)
# Then: the spell is lost with the grapple fizzle log.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [8])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100
assert c.spell_slots_left[1] == 0
assert any(
"casting while grappled: concentration failed (DC 18), spell lost" in line
for line in engine._transcript
)
def test_combat_casting_helps_the_grapple_check(self) -> None:
# Given: a grappled wis-based caster (mod 0) with Combat Casting
# When: the check roll is 9 (9 + 5 + 4 = 18 >= DC 18)
# Then: the cast lands.
caster = _caster(casting_ability="wis", int_score=10, combat_casting=True)
c = _state(caster, "players", (0, 0))
f = _state(_foe(cmb=7), "monsters", (0, 1))
c.grappled_with = "foe"
engine = _engine([c, f], [9, 3, 3])
engine._cast_spell(c, f, "Bolt")
assert f.hp == 100 - 6
def test_normal_cast_still_provokes_the_aoo(self) -> None:
# Given: a threatened caster NOT casting defensively
# When: Bolt is cast (enemy AoO roll 2 misses AC 12)
# Then: the AoO is provoked and the spell still resolves.
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [2, 3, 3]) # AoO d20, then 2d4
engine._cast_spell(c, f, "Bolt")
assert engine._aoo_used.get("foe") == 1
assert f.hp == 100 - 6
def test_action_cast_defensively_threads_through_execution(self) -> None:
# Given: a threatened caster
# When: the cast is issued as an Action with cast_defensively
# Then: _execute dispatches the defensive cast (no AoO, check made).
caster = _caster()
c = _state(caster, "players", (0, 0))
f = _state(_foe(), "monsters", (0, 1))
engine = _engine([c, f], [8, 3, 3])
result = engine._execute(
c,
Action(
kind="cast_spell", target_id="foe", spell_name="Bolt",
cast_defensively=True,
),
)
assert result.ok is True
assert engine._aoo_used == {}
assert f.hp == 100 - 6
+12 -6
View File
@@ -114,9 +114,10 @@ def _make_engine(
caster_pos: tuple[int, int] = (0, 0), caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (3, 0), target_pos: tuple[int, int] = (3, 0),
spell_registry: dict[str, SpellSpec] | None = None, spell_registry: dict[str, SpellSpec] | None = None,
target_side: str = "monsters",
) -> tuple[CombatEngine, CombatantState, CombatantState]: ) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max) c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=target_pos, hp=target.hp_max) t = CombatantState(combatant=target, side=target_side, pos=target_pos, hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)} legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec) grid = Grid.from_spec(spec)
@@ -435,7 +436,8 @@ class TestCureLightWounds:
def test_heals_damage(self) -> None: def test_heals_damage(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=30) target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [3, 3, 5], target_pos=(1, 0)) engine, c, t = _make_engine(caster, target, [3, 5], target_pos=(1, 0),
target_side="players")
t.hp = 10 t.hp = 10
engine._cast_spell(c, t, "Cure Light Wounds") engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 10 + (5 + 1) assert t.hp == 10 + (5 + 1)
@@ -443,7 +445,8 @@ class TestCureLightWounds:
def test_heal_capped_at_max(self) -> None: def test_heal_capped_at_max(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=30) target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0)) engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0),
target_side="players")
t.hp = 28 t.hp = 28
engine._cast_spell(c, t, "Cure Light Wounds") engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 30 assert t.hp == 30
@@ -457,7 +460,8 @@ class TestBullsStrength:
def test_applies_buff_effects(self) -> None: def test_applies_buff_effects(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target() target = _make_target()
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0)) engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Bull's Strength") engine._cast_spell(c, t, "Bull's Strength")
buff_mods = [m for b in t.active_buffs for m in b.modifiers] buff_mods = [m for b in t.active_buffs for m in b.modifiers]
atk_mods = [e for e in buff_mods if e.target == "attack"] atk_mods = [e for e in buff_mods if e.target == "attack"]
@@ -470,7 +474,8 @@ class TestBullsStrength:
def test_transcript_shows_buff(self) -> None: def test_transcript_shows_buff(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target() target = _make_target()
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0)) engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Bull's Strength") engine._cast_spell(c, t, "Bull's Strength")
assert any("buff" in line for line in engine._transcript) assert any("buff" in line for line in engine._transcript)
@@ -614,7 +619,8 @@ class TestActiveIsSingleSource:
def test_legacy_lists_stay_empty_active_lists_populated(self) -> None: def test_legacy_lists_stay_empty_active_lists_populated(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target() target = _make_target()
engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0)) engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0),
target_side="players")
engine._cast_spell(c, t, "Hold Person") engine._cast_spell(c, t, "Hold Person")
engine._cast_spell(c, t, "Bull's Strength") engine._cast_spell(c, t, "Bull's Strength")
assert t.conditions == [] assert t.conditions == []