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 →
|
||||
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
|
||||
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` ;
|
||||
course vers une `destination` hors du budget 4× →
|
||||
`unreachable_destination` (U5e).
|
||||
@@ -579,18 +581,41 @@ Règles modélisées :
|
||||
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
|
||||
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
|
||||
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
|
||||
disponible, l'incantation est refusée** (journalisée `NO SPELL 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
|
||||
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
|
||||
`ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
|
||||
sort (niveau d'emplacement effectif en cas de métamagie, voir ci-dessous) +
|
||||
mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
|
||||
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
|
||||
(`test_dr_does_not_reduce_spell_damage`). Incanter dans une
|
||||
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un
|
||||
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) :
|
||||
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé,
|
||||
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de
|
||||
la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB) ;
|
||||
le don Combat Casting n'accorde son +4 qu'en incantation défensive ou quand
|
||||
on est agrippé, jamais pour les dégâts subis pendant l'incantation. Un sort ciblé avec sauvegarde fait pleins dégâts
|
||||
(`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
|
||||
« Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via
|
||||
`Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les
|
||||
dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
|
||||
inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
|
||||
occupe alors un emplacement de niveau de base + ajustement (Empower +2,
|
||||
Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans
|
||||
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
|
||||
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`
|
||||
@@ -881,6 +906,9 @@ Règles modélisées :
|
||||
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
||||
Les API de décision exposées (items L1-L8)
|
||||
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`.
|
||||
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,
|
||||
|
||||
+184
-34
@@ -202,6 +202,14 @@ _IMPROVED_MANEUVER_FEATS: dict[str, str] = {
|
||||
"reposition": "Improved Reposition",
|
||||
"drag": "Improved Drag",
|
||||
}
|
||||
# PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
|
||||
# level + the feat's adjustment. B7: applied per cast, never blanket-on.
|
||||
_METAMAGIC_SLOT_ADJUST: dict[str, int] = {
|
||||
"Empower Spell": 2,
|
||||
"Maximize Spell": 3,
|
||||
"Extend Spell": 1,
|
||||
}
|
||||
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
|
||||
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
|
||||
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
|
||||
# Strength skills the conformance adjudication groups with the Dex skills.
|
||||
@@ -677,7 +685,7 @@ class Action:
|
||||
outside the map or without line of effect from the caster is rejected
|
||||
(``invalid_destination``); with ``target_pos`` and no resolved target the
|
||||
spell is cast on the point and every creature in the area rolls its own
|
||||
save. ``aid_another`` is a standard action helping a resolved ally: the
|
||||
save. ``aid_another`` is a standard action helping a resolved ally: the
|
||||
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
|
||||
against AC 10; on success the ally gains +2 on its next attack roll
|
||||
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
||||
@@ -686,6 +694,15 @@ class Action:
|
||||
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
||||
``running``, which lasts until the runner's next turn and denies the
|
||||
runner its Dexterity bonus to AC.
|
||||
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
|
||||
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
|
||||
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
|
||||
apply; the spell then occupies a slot of base level + adjustment
|
||||
(Empower +2, Maximize +3, Extend +1) and its save DC uses that slot
|
||||
level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
|
||||
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
|
||||
but must pass a concentration check (DC 15 + 2 x spell level, +4 with
|
||||
Combat Casting) or the spell fizzles.
|
||||
"""
|
||||
|
||||
kind: Literal[
|
||||
@@ -713,6 +730,10 @@ class Action:
|
||||
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
|
||||
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
|
||||
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
|
||||
# B7: metamagic feats to apply to THIS cast (only known ones apply)
|
||||
metamagic: tuple[str, ...] = ()
|
||||
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles
|
||||
cast_defensively: bool = False
|
||||
|
||||
|
||||
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
||||
@@ -2788,7 +2809,7 @@ class CombatEngine:
|
||||
"""
|
||||
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
||||
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
|
||||
has_combat_casting = self._has_feat(caster, "Combat Casting")
|
||||
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
|
||||
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
||||
roll = self._rng.d20()
|
||||
total = (
|
||||
@@ -3306,7 +3327,8 @@ class CombatEngine:
|
||||
kind = action.kind
|
||||
if kind == "cast_spell":
|
||||
return self._execute_cast_spell(
|
||||
state, target, action.spell_name, target_pos=action.target_pos
|
||||
state, target, action.spell_name, target_pos=action.target_pos,
|
||||
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
||||
)
|
||||
if kind == "maneuver":
|
||||
self._execute_maneuver(
|
||||
@@ -3390,7 +3412,8 @@ class CombatEngine:
|
||||
state.immediate_used = True
|
||||
if action.spell_name is not None:
|
||||
self._execute_cast_spell(
|
||||
state, target, action.spell_name, target_pos=action.target_pos
|
||||
state, target, action.spell_name, target_pos=action.target_pos,
|
||||
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
||||
)
|
||||
elif action.maneuver is not None:
|
||||
self._execute_maneuver(state, target, action.maneuver)
|
||||
@@ -3507,10 +3530,14 @@ class CombatEngine:
|
||||
spell_name: str | None,
|
||||
*,
|
||||
target_pos: tuple[int, int] | None = None,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
cast_defensively: bool = False,
|
||||
) -> ActionResult | None:
|
||||
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
|
||||
validated first — an off-map point or one without line of effect
|
||||
from the caster is rejected without casting."""
|
||||
from the caster is rejected without casting. ``metamagic`` (B7)
|
||||
selects the feats applied to THIS cast; ``cast_defensively`` (B24)
|
||||
trades the casting AoO for a concentration check."""
|
||||
if self.has_condition_flag(state, "cant_attack"):
|
||||
# PF1e (CRB): a nauseated creature cannot cast spells or
|
||||
# concentrate; the cant_attack flag blocks the cast entirely.
|
||||
@@ -3525,8 +3552,10 @@ class CombatEngine:
|
||||
return rejection
|
||||
if spell_name is None:
|
||||
return None
|
||||
self._cast_spell(state, target, spell_name, center_pos=target_pos)
|
||||
return None
|
||||
return self._cast_spell(
|
||||
state, target, spell_name, center_pos=target_pos,
|
||||
metamagic=metamagic, cast_defensively=cast_defensively,
|
||||
)
|
||||
|
||||
def _point_target_rejection(
|
||||
self, state: CombatantState, target_pos: tuple[int, int]
|
||||
@@ -4361,9 +4390,16 @@ class CombatEngine:
|
||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
||||
return (ability_score - 10) // 2
|
||||
|
||||
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
|
||||
"""Spell save DC: 10 + spell level + casting ability modifier."""
|
||||
return 10 + spell.level + self._casting_ability_mod(caster)
|
||||
def _spell_dc(
|
||||
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
|
||||
) -> int:
|
||||
"""Spell save DC: 10 + spell level + casting ability modifier.
|
||||
|
||||
PF1e (CRB, B7): a metamagic spell uses the level of the slot cast
|
||||
from, so ``slot_level`` overrides the spell's base level when given.
|
||||
"""
|
||||
level = spell.level if slot_level is None else slot_level
|
||||
return 10 + level + self._casting_ability_mod(caster)
|
||||
|
||||
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
|
||||
"""One per-spell dict for the Observation, enriched from the registry."""
|
||||
@@ -4402,7 +4438,9 @@ class CombatEngine:
|
||||
spell_name: str,
|
||||
*,
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
metamagic: tuple[str, ...] = (),
|
||||
cast_defensively: bool = False,
|
||||
) -> ActionResult | None:
|
||||
"""Cast ``spell_name``.
|
||||
|
||||
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell
|
||||
@@ -4410,7 +4448,12 @@ class CombatEngine:
|
||||
spells carrying an area; every other effect still resolves on the
|
||||
target. A cast with no resolved target requires ``center_pos`` and an
|
||||
area effect — the primary save is skipped and each creature in the
|
||||
area rolls its own.
|
||||
area rolls its own. ``metamagic`` (B7) lists the feats applied to
|
||||
THIS cast (only feats the caster knows apply, and the spell then
|
||||
occupies — and must have — a slot of base level + adjustment).
|
||||
``cast_defensively`` (B24) skips the casting AoO for a concentration
|
||||
check. Returns an explicit rejection (``no_slot``) when the required
|
||||
slot is unavailable; None means the cast proceeded through the gates.
|
||||
"""
|
||||
spell = self._spell_registry.get(spell_name)
|
||||
if spell is None:
|
||||
@@ -4418,31 +4461,128 @@ class CombatEngine:
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} -> UNKNOWN SPELL"
|
||||
)
|
||||
return
|
||||
return None
|
||||
# PF1e (CRB, Metamagic Feats): metamagic is chosen per cast and only
|
||||
# the feats the caster actually knows can be applied (B7).
|
||||
known = set(caster.combatant.metamagic_feats)
|
||||
applied = tuple(f for f in metamagic if f in known)
|
||||
used_level = spell.level + sum(
|
||||
_METAMAGIC_SLOT_ADJUST.get(f, 0) for f in applied
|
||||
)
|
||||
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
||||
if self._spell_cast_rejected(
|
||||
caster, target, spell, spell_name, center_pos, has_area=has_area
|
||||
):
|
||||
return
|
||||
return None
|
||||
# PF1e (CRB): a caster cannot cast without an available slot of the
|
||||
# spell's level; cantrips (level 0) are cast freely. The slot is spent
|
||||
# up-front — a later failed concentration check still burns it.
|
||||
if spell.level > 0 and not caster.spend_spell_slot(spell.level):
|
||||
# spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
|
||||
# freely. The slot is spent up-front — a later failed concentration
|
||||
# check or missed touch attack still burns it.
|
||||
if used_level > 0 and not caster.spend_spell_slot(used_level):
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} -> NO SPELL SLOT (level {spell.level})"
|
||||
f"cast {spell_name} -> NO SPELL SLOT (level {used_level})"
|
||||
)
|
||||
return
|
||||
# Check for AoO from casting in threatened square (before resolving spell)
|
||||
if self._check_casting_aoo(caster, spell.level):
|
||||
return
|
||||
return ActionResult(
|
||||
kind="cast_spell", ok=False, reason="no_slot",
|
||||
detail=f"level {used_level}",
|
||||
)
|
||||
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
|
||||
# must clear their interruption gate before the spell is delivered.
|
||||
if self._spell_cast_interrupted(
|
||||
caster, used_level, cast_defensively=cast_defensively
|
||||
):
|
||||
return None
|
||||
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
|
||||
# and spell resistance can still turn the spell away.
|
||||
if self._spell_delivery_blocked(caster, target, spell, has_area=has_area):
|
||||
return None
|
||||
self._apply_and_log_cast(
|
||||
caster, target, spell, spell_name, center_pos,
|
||||
metamagic=applied, slot_level=used_level,
|
||||
)
|
||||
return None
|
||||
|
||||
def _spell_cast_interrupted(
|
||||
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool
|
||||
) -> bool:
|
||||
"""Pre-delivery interruption gate (B24).
|
||||
|
||||
PF1e (CRB, Concentration): while grappled the caster must pass
|
||||
DC 10 + grappler's CMB + spell level to cast at all; casting
|
||||
defensively forgoes the provocation but requires DC 15 + 2x spell
|
||||
level (Combat Casting's +4 applies to both); otherwise casting in a
|
||||
threatened square provokes the AoO (with the injured-casting check).
|
||||
True = the spell is lost (the slot is already spent).
|
||||
"""
|
||||
if caster.grappled_with is not None or cast_defensively:
|
||||
if not self._check_concentration(caster, spell_level, 0):
|
||||
dc = self._concentration_dc(caster, spell_level, 0)
|
||||
situation = (
|
||||
"while grappled" if caster.grappled_with is not None else "defensively"
|
||||
)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"casting {situation}: concentration failed (DC {dc}), spell lost"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
return self._check_casting_aoo(caster, spell_level)
|
||||
|
||||
def _spell_delivery_blocked(
|
||||
self,
|
||||
caster: CombatantState,
|
||||
target: CombatantState | None,
|
||||
spell: SpellSpec,
|
||||
*,
|
||||
has_area: bool,
|
||||
) -> bool:
|
||||
"""Delivery gates: a hostile touch spell must land its melee touch
|
||||
attack (B14), then spell resistance must be overcome. True = no
|
||||
effect (the slot is already spent)."""
|
||||
if (
|
||||
target is not None
|
||||
and spell.range == "touch"
|
||||
and target.side != caster.side
|
||||
and not self._spell_touch_attack_hits(caster, target)
|
||||
):
|
||||
return True
|
||||
return (
|
||||
target is not None
|
||||
and not has_area
|
||||
and not self._check_spell_resistance(caster, target)
|
||||
):
|
||||
return
|
||||
self._apply_and_log_cast(caster, target, spell, spell_name, center_pos)
|
||||
)
|
||||
|
||||
def _spell_touch_attack_hits(
|
||||
self, caster: CombatantState, target: CombatantState
|
||||
) -> bool:
|
||||
"""Melee touch attack to deliver a touch-range spell (B14).
|
||||
|
||||
PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch
|
||||
AC. Engine simplification (documented): the bonus is BAB + Strength
|
||||
modifier — Weapon Finesse and other situational touch bonuses are
|
||||
not modeled; natural 20 hits and natural 1 misses per the engine's
|
||||
attack-roll convention. The touch AC reuses the resolve_attack
|
||||
denies-Dex path (flat-footed/running target, invisible attacker).
|
||||
"""
|
||||
roll = self._rng.d20()
|
||||
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod
|
||||
denies_dex = (
|
||||
self.has_condition_flag(target, "denies_dex_to_ac")
|
||||
or target.running
|
||||
or self._has_condition_named(caster, "invisible")
|
||||
)
|
||||
if denies_dex:
|
||||
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||
else:
|
||||
ac = target.combatant.ac.touch
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: touch attack vs "
|
||||
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
||||
f"-> {'HIT' if hit else 'MISS'}"
|
||||
)
|
||||
return hit
|
||||
|
||||
def _spell_cast_rejected(
|
||||
self,
|
||||
@@ -4512,9 +4652,18 @@ class CombatEngine:
|
||||
spell: SpellSpec,
|
||||
spell_name: str,
|
||||
center_pos: tuple[int, int] | None,
|
||||
*,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
slot_level: int | None = None,
|
||||
) -> None:
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates)."""
|
||||
dc = self._spell_dc(caster, spell)
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates).
|
||||
|
||||
``metamagic`` holds the feats chosen for THIS cast (already filtered
|
||||
to known ones, B7); ``slot_level`` is the spell's level in the slot
|
||||
used — the save DC is computed from it (PF1e CRB: a metamagic spell
|
||||
uses the DC of the higher slot level).
|
||||
"""
|
||||
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
||||
save_result = (
|
||||
self.resolve_save(
|
||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||
@@ -4526,15 +4675,14 @@ class CombatEngine:
|
||||
target_live = (
|
||||
self._stats[target.combatant.id] if target is not None else caster_live
|
||||
)
|
||||
# Apply metamagic feats to spell effects
|
||||
metamagic_feats = caster.combatant.metamagic_feats
|
||||
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
|
||||
# Apply only the metamagic feats chosen for this cast (B7)
|
||||
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
||||
hp_before = target.hp if target is not None else caster.hp
|
||||
parts = self._apply_spell_effects_from_list(
|
||||
metamagic_effects, target, save_result, caster_live, target_live,
|
||||
caster=caster, spell_name=spell.name,
|
||||
empower="Empower Spell" in metamagic_feats,
|
||||
maximize="Maximize Spell" in metamagic_feats,
|
||||
empower="Empower Spell" in metamagic,
|
||||
maximize="Maximize Spell" in metamagic,
|
||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
||||
)
|
||||
save_str = _save_label(save_result)
|
||||
@@ -4856,9 +5004,9 @@ class CombatEngine:
|
||||
return save_result.success
|
||||
|
||||
def _apply_metamagic(
|
||||
self, spell: SpellSpec, metamagic_feats: list[str]
|
||||
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
||||
) -> tuple[SpellEffect, ...]:
|
||||
"""Apply metamagic feats to spell effects on the fly."""
|
||||
"""Apply the feats chosen for THIS cast to the spell's effects (B7)."""
|
||||
if not metamagic_feats:
|
||||
return spell.effects
|
||||
has_extend = "Extend Spell" in metamagic_feats
|
||||
@@ -5582,6 +5730,8 @@ def _ready_payload(action: Action) -> Action:
|
||||
target_id=action.target_id,
|
||||
spell_name=action.spell_name,
|
||||
target_pos=action.target_pos,
|
||||
metamagic=action.metamagic,
|
||||
cast_defensively=action.cast_defensively,
|
||||
)
|
||||
if action.maneuver is not None:
|
||||
return Action(
|
||||
|
||||
@@ -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),
|
||||
target_pos: tuple[int, int] = (3, 0),
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
target_side: str = "monsters",
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
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)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
@@ -435,7 +436,8 @@ class TestCureLightWounds:
|
||||
def test_heals_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
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
|
||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||
assert t.hp == 10 + (5 + 1)
|
||||
@@ -443,7 +445,8 @@ class TestCureLightWounds:
|
||||
def test_heal_capped_at_max(self) -> None:
|
||||
caster = _make_caster()
|
||||
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
|
||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||
assert t.hp == 30
|
||||
@@ -457,7 +460,8 @@ class TestBullsStrength:
|
||||
def test_applies_buff_effects(self) -> None:
|
||||
caster = _make_caster()
|
||||
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")
|
||||
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"]
|
||||
@@ -470,7 +474,8 @@ class TestBullsStrength:
|
||||
def test_transcript_shows_buff(self) -> None:
|
||||
caster = _make_caster()
|
||||
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")
|
||||
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:
|
||||
caster = _make_caster()
|
||||
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, "Bull's Strength")
|
||||
assert t.conditions == []
|
||||
|
||||
Reference in New Issue
Block a user