diff --git a/README.md b/README.md index fd02a1d..2d1c040 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 452e6d4..9089866 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py new file mode 100644 index 0000000..50fc98b --- /dev/null +++ b/tests/test_conformance_spellflow.py @@ -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 diff --git a/tests/test_spells.py b/tests/test_spells.py index 772cf0c..146c4eb 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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 == []