fix(rules): correct metamagic slots, touch-spell delivery, and defensive/grapple casting
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+184
-34
@@ -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.
|
||||||
@@ -677,7 +685,7 @@ class Action:
|
|||||||
outside the map or without line of effect from the caster is rejected
|
outside the map or without line of effect from the caster is rejected
|
||||||
(``invalid_destination``); with ``target_pos`` and no resolved target the
|
(``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
|
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)
|
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
|
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
|
(``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
|
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(
|
||||||
|
|||||||
@@ -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
@@ -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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user