diff --git a/README.md b/README.md index ac8bf99..84eaf35 100644 --- a/README.md +++ b/README.md @@ -350,8 +350,12 @@ Règles modélisées : - Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant ajoute `prone` à la liste `conditions` **sans durée**, donc la condition persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent - `active_conditions` avec 1 round). Les modificateurs de `prone` (−4 CA, −4 aux - attaques à distance) s'appliquent immédiatement. Si la condition est déjà + `active_conditions` avec 1 round). Les modificateurs de `prone` s'appliquent + immédiatement : l'attaquant à plat ventre subit −4 à ses attaques (de mêlée + selon le CRB ; les armes à distance sont traitées comme des arbalètes — + utilisables à −4, simplification documentée), la cible à plat ventre subit + −4 CA contre les attaques de mêlée et gagne +4 CA contre les attaques à + distance. Si la condition est déjà présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de cible. `default_policy` ne la choisit pas (disponible via une politique personnalisée). @@ -555,15 +559,19 @@ Règles modélisées : condition inconnue ou non supportée reste exclue (False). - 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`). Le DD vaut `10 + niveau du + 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 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 CON - (+4 avec le don Combat Casting). Un sort ciblé avec sauvegarde fait pleins dégâts + 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 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` @@ -609,9 +617,15 @@ Règles modélisées : de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course). L'invisibilité est symétrique : subi comme cible, la condition accorde le concealment total de 50 % ci-dessus. -- Cible inerme (helpless, V5a) : les attaques de **mêlée** contre une cible - `helpless` gagnent **+4 au jet d'attaque et à la confirmation** (aucun bonus à - distance) ; `helpless` porte `denies_dex_to_ac` (CA flat-footed) + `cant_act`. +- Cible inerme (helpless, V5a + conformance P1) : sont traitées comme + **inertes** les conditions `helpless`, `paralyzed`, `asleep` et + `unconscious` (CRB : inerme = paralysé, immobilisé, ligoté, endormi, + inconscient — `stunned` **ne** rend **pas** inerme). Les attaques de + **mêlée** contre une cible inerme gagnent **+4 au jet d'attaque et à la + confirmation** (aucun bonus à distance) ; la cible a une **Dextérité + effective de 0** : son modificateur de Dextérité est entièrement remplacé + par la pénalité Dex 0 (−5), en plus de la perte du bonus de Dextérité + (CA flat-footed via `denies_dex_to_ac`). Simplification documentée : l'attaque sournoise automatique du roublard sur cible inerme n'est pas modélisée (la cible, privée de Dextérité, la déclenche déjà via le chemin `denies_dex_to_ac`). @@ -639,15 +653,22 @@ Règles modélisées : - Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action - simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action + de mouvement (`move`, `5foot_step`, `stand_up`, `drop_prone`) et bloque toute + attaque, manœuvre **et incantation** (CRB : un nauséeux ne peut ni attaquer, + ni incanter, ni se concentrer) ; `staggered` restreint à une action simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; - `denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à + `denies_dex_to_ac` (flat-footed, paralysé, aveuglé…) fait perdre le bonus de Dextérité à la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible 50 %) impose la - chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance. + chance de rater du concealment. À plat ventre : l'attaquant à plat ventre + subit −4 à ses attaques (mêlée selon le CRB ; distance traitée arbalète à + −4, simplification documentée) ; la cible à plat ventre subit −4 CA contre + la mêlée et gagne +4 CA contre la distance (le CRB inverse du « −4 CA + contre tout » précédemment modélisé). Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO. - Inerme (`helpless`) : ne peut pas agir, perd sa Dextérité à la CA, +4 en mêlée - contre lui (voir « Cible inerme »). Fasciné (`fascinated`) : ne peut pas agir. + Inerme (`helpless`, `paralyzed`, `asleep`, `unconscious`) : ne peut pas agir, + Dextérité effective 0 (−5 CA en plus de la perte du bonus), +4 en mêlée + contre lui (voir « Cible inerme ») ; `stunned` ne rend pas inerte. Fasciné (`fascinated`) : ne peut pas agir. Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex de la cible (voir « Attaquant invisible »). Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage` @@ -690,8 +711,12 @@ Règles modélisées : - Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence` (evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à - zone (AoE) utilisent le DD d'incantation du jeteur et, sur une sauvegarde réussie, - ne font que la moitié des dégâts. + zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la + zone effectue la sauvegarde listée par le sort (Volonté / Réflexes / + Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès + annule ou divise par deux selon ce que l'effet déclare (`save_negates` / + `half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde + annule » (Sleep, Color Spray, Web) sauvent donc aussi. - Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f6a1d0e..dcc55ff 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -101,6 +101,7 @@ from pf1e_simulator.spells import ( ConditionEffect, DamageEffect, HealEffect, + SaveType, SpellEffect, SummonEffect, save_descriptors_for_effects, @@ -166,6 +167,13 @@ _CONFUSED_BABBLE_MAX = 50 _CONFUSED_SELF_DAMAGE_MAX = 75 _INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker _HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless +# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious — +# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5 +# modifier for a Medium creature (CRB, Helpless condition). +_DEX_ZERO_MOD = -5 +_HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset( + {"helpless", "paralyzed", "asleep", "unconscious"} +) _AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10 _AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC) _RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed @@ -1341,6 +1349,16 @@ class CombatEngine: or any(cond.name == name for cond in state.conditions) ) + def _is_helpless(self, state: CombatantState) -> bool: + """PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious. + + Stunned is NOT helpless. A helpless creature has effective Dexterity 0 + and melee attackers gain +4 (resolve_attack).""" + return any( + self._has_condition_named(state, name) + for name in _HELPLESS_CONDITION_NAMES + ) + def _modifier_condition_holds( self, _state: CombatantState, @@ -1805,11 +1823,9 @@ class CombatEngine: if m.target == target and (m.weapon_filter is None or m.weapon_filter == weapon_name) ) - if target == "ac": - if self._has_condition_named(state, "prone"): - mods.append(StatModifier(target="ac", value=-4)) - if self._has_condition_named(state, "paralyzed"): - mods.append(StatModifier(target="ac", value=-4)) + # PF1e: prone and helpless (paralyzed/asleep/unconscious) AC + # adjustments depend on the weapon kind and are applied in + # resolve_attack, where it is known — not unconditionally here. return [ m for m in mods @@ -1901,6 +1917,29 @@ class CombatEngine: return 0 return max(1, attacker.combatant.bab // 4 + 1) + def _attack_ac_mods( + self, target: CombatantState, weapon: AttackSpec + ) -> list[StatModifier]: + """AC modifiers for one attack — the weapon-kind-dependent ones included. + + PF1e (CRB): a prone defender takes -4 AC against melee attacks but + gains +4 AC against ranged attacks; a helpless defender (paralyzed, + asleep, unconscious, helpless) has an effective Dexterity score of 0, + so its whole Dex modifier gives way to the Dex-0 penalty (-5) — + flat-footed AC already drops a positive Dex bonus, a native Dex + penalty must still be replaced.""" + mods = self._stat_modifiers(target, "ac") + if self._has_condition_named(target, "prone"): + mods.append( + StatModifier( + target="ac", value=4 if self._is_ranged_attack(weapon) else -4 + ) + ) + if self._is_helpless(target): + dex_penalty = min(0, target.combatant.abilities.dex_mod) + mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD - dex_penalty)) + return mods + def resolve_attack( self, attacker: CombatantState, @@ -1929,14 +1968,21 @@ class CombatEngine: atk_mods = self._stat_modifiers( attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft ) - if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"): + # PF1e (CRB): a prone attacker takes -4 on melee attack rolls. Ranged + # weapons cannot be used while prone at all, EXCEPT crossbows, which + # stay usable at -4. The model cannot tell a crossbow from a bow, so + # every ranged weapon is treated as the crossbow-exempt case: usable, + # but at -4 (documented simplification). + if self._has_condition_named(attacker, "prone"): atk_mods.append(StatModifier(target="attack", value=-4)) # PF1e: an invisible attacker strikes from an unknown direction (+2 on # attack and crit confirmation, target loses its Dex bonus to AC). if self._has_condition_named(attacker, "invisible"): atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS)) - # PF1e: melee attacks against a helpless target gain +4 (ranged none). - if weapon.kind == "melee" and self._has_condition_named(target, "helpless"): + # PF1e (CRB): melee attacks against a helpless target gain +4; the + # helpless state includes paralyzed, asleep and unconscious (stunned + # is NOT helpless). Ranged attacks gain no bonus. + if weapon.kind == "melee" and self._is_helpless(target): atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS)) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 @@ -1976,7 +2022,8 @@ class CombatEngine: base_ac = target.combatant.ac.touch else: base_ac = full_ac_base - ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac")) + ac_mods = self._attack_ac_mods(target, weapon) + ac = base_ac + resolve_modifiers(ac_mods) ac += target.defensive_ac_bonus ac += target.aid_ac_bonus occupied = frozenset( @@ -2609,17 +2656,19 @@ class CombatEngine: ) -> bool: """Concentration check: DC depends on situation. - Roll = d20 + caster_level + casting ability mod (+4 Combat Casting). + PF1e (CRB): the check is d20 + caster level + the ability modifier + used to determine bonus spells (Int/Wis/Cha) — NOT Constitution. + Combat Casting's +4 applies only when casting defensively or while + grappled/pinned, never for damage taken while casting. """ dc = self._concentration_dc(caster, spell_level, damage_taken) - has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features) - bonus = 4 if has_combat_casting else 0 + injured_while_casting = damage_taken > 0 and caster.grappled_with is None + has_combat_casting = self._has_feat(caster, "Combat Casting") + bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0) roll = self._rng.d20() - # Use caster level + ability mod for concentration check - caster_level = caster.combatant.level - # For concentration, the check is d20 + caster_level + ability_mod (usually Con) - con_mod = (caster.combatant.abilities.con_score - 10) // 2 - total = roll + caster_level + con_mod + bonus + total = ( + roll + caster.combatant.level + self._casting_ability_mod(caster) + bonus + ) return total >= dc def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool: """True if a 5-foot step toward ``target`` would put ``state`` in weapon range.""" @@ -3334,6 +3383,14 @@ class CombatEngine: """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.""" + 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. + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"cast_spell rejected (cannot attack)" + ) + return None if target_pos is not None: rejection = self._point_target_rejection(state, target_pos) if rejection is not None: @@ -4142,12 +4199,15 @@ class CombatEngine: return self._charge(state, target, result.weapon) - def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int: - """Spell save DC: 10 + spell level + casting ability modifier.""" + def _casting_ability_mod(self, caster: CombatantState) -> int: + """Modifier of the casting ability (the one used for bonus spells).""" ability_name = caster.combatant.casting_ability.lower() ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") - ability_mod = (ability_score - 10) // 2 - return 10 + spell.level + ability_mod + 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_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]: """One per-spell dict for the Observation, enriched from the registry.""" @@ -4208,10 +4268,18 @@ class CombatEngine: caster, target, spell, spell_name, center_pos, has_area=has_area ): return + # 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): + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} -> NO SPELL SLOT (level {spell.level})" + ) + return # Check for AoO from casting in threatened square (before resolving spell) if self._check_casting_aoo(caster, spell.level): return - caster.spend_spell_slot(spell.level) if ( target is not None and not has_area @@ -4311,7 +4379,7 @@ class CombatEngine: caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats, - dc=dc, center_pos=center_pos, + dc=dc, center_pos=center_pos, save_type=spell.save, ) save_str = _save_label(save_result) if target is None and center_pos is not None: @@ -4349,7 +4417,7 @@ class CombatEngine: """Legacy wrapper for compatibility.""" return self._apply_spell_effects_from_list( spell.effects, target, save_result, caster_live, target_live, - caster=target, spell_name=spell.name + caster=target, spell_name=spell.name, save_type=spell.save ) def _apply_spell_effects_from_list( @@ -4366,6 +4434,7 @@ class CombatEngine: maximize: bool = False, dc: int = 15, center_pos: tuple[int, int] | None = None, + save_type: SaveType = "none", ) -> list[str]: saved = save_result is not None and save_result.success return [ @@ -4373,7 +4442,7 @@ class CombatEngine: effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, - dc=dc, center_pos=center_pos, + dc=dc, center_pos=center_pos, save_type=save_type, ) for effect in effects ] @@ -4392,13 +4461,14 @@ class CombatEngine: saved: bool, dc: int = 15, center_pos: tuple[int, int] | None = None, + save_type: SaveType = "none", ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, saved=saved, empower=empower, maximize=maximize, dc=dc, - center_pos=center_pos, + center_pos=center_pos, save_type=save_type, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) @@ -4535,6 +4605,7 @@ class CombatEngine: maximize: bool = False, dc: int = 15, center_pos: tuple[int, int] | None = None, + save_type: SaveType = "none", ) -> str: """Apply an area effect to all creatures within radius. @@ -4558,7 +4629,8 @@ class CombatEngine: if not self._check_spell_resistance(caster, affected_state): continue creature_saved = self._area_save( - affected_state, target, effect, saved=saved, dc=dc + affected_state, target, effect, saved=saved, dc=dc, + save_type=save_type, ) for sub_effect in effect.effects: target_live = self._stats[affected_state.combatant.id] @@ -4603,19 +4675,27 @@ class CombatEngine: *, saved: bool, dc: int = 15, + save_type: SaveType = "none", ) -> bool: """Save result for one creature in an area effect. + PF1e (CRB): every creature in the area rolls the save the SPELL lists + (Will / Fortitude / Reflex) — "save negates" and "save for half" both + grant it; whether a success negates or halves is decided per sub-effect + (``save_negates`` / ``half_on_save``), never by skipping the roll. The resolved primary target re-uses its already-rolled save; every other creature in the area — and every creature of a point-targeted burst (no primary target, U7b) — rolls its own. """ - if not effect.save_for_half: + if save_type == "none": return False if affected_state is target: return saved save_result = self.resolve_save( - affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects) + affected_state, + save_type, + dc, + save_descriptors=save_descriptors_for_effects(effect.effects), ) return save_result.success @@ -5277,10 +5357,12 @@ def _choose_spell_action( _FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"}) -_STANDARD_KINDS: frozenset[str] = frozenset({ - "attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy", -}) _SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"}) +# PF1e (CRB): a nauseated creature can take only a single move action per +# turn — no standard, no full-round, no attacks, no spellcasting. +_NAUSEA_MOVE_KINDS: frozenset[str] = frozenset( + {"move", "5foot_step", "stand_up", "drop_prone"} +) def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]: @@ -5316,9 +5398,13 @@ def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: - """Nauseated: single standard action only.""" + """PF1e (CRB) nauseated: a single move action per turn, nothing else. + + The sequence collapses to its first move-like action; standard actions + (attack, cast_spell, maneuver, ...) and full-round actions are dropped. + """ for action in actions: - if action.kind in _STANDARD_KINDS: + if action.kind in _NAUSEA_MOVE_KINDS: return (action,) return () diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index d7b54e1..9a95d90 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -205,12 +205,11 @@ DAZED = Condition( cant_act=True, ) -# Asleep/Unconscious: helpless, can't act, -4 AC vs melee, no Dex to AC (CRB) +# Asleep/Unconscious: helpless (CRB) — can't act, no Dex to AC. The old flat +# -4 AC approximation is superseded by the engine's Dex-0 (-5) adjustment for +# helpless targets, applied in resolve_attack (CombatEngine._is_helpless). ASLEEP = Condition( name="asleep", - modifiers=( - StatModifier(target="ac", value=-4), - ), denies_dex_to_ac=True, cant_act=True, cant_move=True, @@ -218,7 +217,10 @@ ASLEEP = Condition( # Helpless: helpless for all intents, treated as Dex 0 (loses Dex to AC), -# can't act; melee attackers gain +4 (engine logic in resolve_attack) (CRB) +# can't act; melee attackers gain +4 (engine logic in resolve_attack). The +# engine also treats paralyzed/asleep/unconscious as helpless (CRB: "helpless" +# = paralyzed, held, bound, sleeping, unconscious; stunned is NOT helpless) — +# see CombatEngine._is_helpless (CRB) HELPLESS = Condition( name="helpless", denies_dex_to_ac=True, diff --git a/tests/test_action_feedback.py b/tests/test_action_feedback.py index cc7d58a..852d80d 100644 --- a/tests/test_action_feedback.py +++ b/tests/test_action_feedback.py @@ -160,8 +160,8 @@ def test_staggered_full_attack_downgrade_recorded_with_ok_attack() -> None: def test_nauseated_full_attack_downgrade_recorded() -> None: - # Nauseated: full_attack is not a standard action → the filter empties the - # sequence; only the synthetic downgrade result remains. + # Nauseated (B6): only move-like actions survive the filter — full_attack + # is dropped; only the synthetic downgrade result remains. eng = _engine( [10, 5], policy=_fixed_policy(Action(kind="full_attack", target_id="foe")), diff --git a/tests/test_concentration.py b/tests/test_concentration.py index 5138dbc..37c0dcb 100644 --- a/tests/test_concentration.py +++ b/tests/test_concentration.py @@ -5,12 +5,15 @@ The concentration DC depends on the casting situation (CRB Table 9-1): - grappled or pinned: DC = 10 + grappler's CMB + spell_level - casting defensively: DC = 15 + 2 * spell_level -The check roll = d20 + caster_level + casting ability mod (+4 Combat Casting), -unchanged from the current logic. +The check roll = d20 + caster level + the casting ability modifier used to +determine bonus spells (Int/Wis/Cha) — NOT Constitution (PF1e conformance +fix B2). Combat Casting's +4 applies only when casting defensively or while +grappled — never for damage taken while casting. """ from __future__ import annotations +from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid @@ -32,6 +35,11 @@ def _combatant( level: int = 5, con: int = 12, cmb: int | None = None, + casting_ability: str = "wis", + int_score: int = 10, + wis_score: int = 10, + cha_score: int = 10, + combat_casting: bool = False, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", @@ -40,6 +48,9 @@ def _combatant( 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, @@ -49,9 +60,9 @@ def _combatant( str_score=10, dex_score=10, con_score=con, - int_score=10, - wis_score=10, - cha_score=10, + int_score=int_score, + wis_score=wis_score, + cha_score=cha_score, ), hp_max=30, ac=ACProfile(total=12, touch=12, flat_footed=10), @@ -61,6 +72,8 @@ def _combatant( saves=Saves(fort=0, ref=0, will=0), attacks=[attack], cmb=cmb, + casting_ability=casting_ability, + features=features, ) @@ -113,3 +126,52 @@ class TestConcentrationDc: assert engine_pass._check_concentration(c, 1, damage_taken=4) is True engine_fail = _make_engine(caster, grappler, [9])[0] assert engine_fail._check_concentration(c, 1, damage_taken=4) is False + + +class TestConcentrationUsesCastingAbilityNotCon: + """B2 (PF1e conformance): the check is d20 + caster level + the ability + modifier used for bonus spells (Int/Wis/Cha) — NOT Constitution.""" + + def test_high_con_low_casting_ability_fails_where_con_would_pass(self) -> None: + # CON 18 (+4) but casting ability INT 10 (+0): injured DC = 10+4+1 = 15. + # Correct roll: d20 6 + level 5 + int 0 = 11 < 15 -> FAIL. + # The old CON-based check would have given 6 + 5 + 4 = 15 -> pass. + caster = _combatant("caster", level=5, con=18, casting_ability="int") + grappler = _combatant("grappler") + engine, c, _ = _make_engine(caster, grappler, [6]) + assert engine._check_concentration(c, 1, damage_taken=4) is False + + def test_charisma_modifier_is_used_for_a_sorcerer(self) -> None: + # CHA 18 (+4), CON 6 (-2), casting ability cha: injured DC = 10+4+1 = 15. + # d20 6 + level 5 + cha 4 = 15 >= 15 -> PASS (CON would give 13 -> fail). + caster = _combatant("caster", level=5, con=6, cha_score=18, casting_ability="cha") + grappler = _combatant("grappler") + engine, c, _ = _make_engine(caster, grappler, [6]) + assert engine._check_concentration(c, 1, damage_taken=4) is True + + +class TestCombatCastingPlacement: + """B2 (PF1e conformance): Combat Casting's +4 applies only when casting + defensively or while grappled — never for damage taken while casting.""" + + def test_combat_casting_does_not_help_when_injured(self) -> None: + # Injured DC = 10+4+1 = 15. d20 6 + level 5 + wis 0 = 11 < 15 -> FAIL. + # The old blanket +4 would have made 15 -> pass. + caster = _combatant("caster", level=5, con=10, combat_casting=True) + grappler = _combatant("grappler") + engine, c, _ = _make_engine(caster, grappler, [6]) + assert engine._check_concentration(c, 1, damage_taken=4) is False + + def test_combat_casting_helps_when_casting_defensively(self) -> None: + # Defensive DC = 15 + 2*1 = 17. d20 8 + level 5 + wis 0 + 4 = 17 -> PASS. + caster = _combatant("caster", level=5, con=10, combat_casting=True) + grappler = _combatant("grappler") + engine, c, _ = _make_engine(caster, grappler, [8]) + assert engine._check_concentration(c, 1, damage_taken=0) is True + + def test_combat_casting_helps_while_grappled(self) -> None: + # Grappled DC = 10 + CMB 3 + level 1 = 14. d20 5 + 5 + 0 + 4 = 14 -> PASS. + caster = _combatant("caster", level=5, con=10, combat_casting=True) + grappler = _combatant("grappler", cmb=3) + engine, c, _ = _make_engine(caster, grappler, [5], grappled=True) + assert engine._check_concentration(c, 1, damage_taken=0) is True diff --git a/tests/test_conditional_modifiers.py b/tests/test_conditional_modifiers.py index 3f795be..a83252e 100644 --- a/tests/test_conditional_modifiers.py +++ b/tests/test_conditional_modifiers.py @@ -380,6 +380,7 @@ def _make_caster(cid: str = "caster") -> Combatant: saves=Saves(fort=3, ref=4, will=4), attacks=[attack], casting_ability="int", + spell_slots={0: 99, 1: 99, 2: 99, 3: 99}, ) diff --git a/tests/test_conditions_blinded_invisible.py b/tests/test_conditions_blinded_invisible.py index f9a0543..6c29f8b 100644 --- a/tests/test_conditions_blinded_invisible.py +++ b/tests/test_conditions_blinded_invisible.py @@ -164,9 +164,10 @@ def test_melee_vs_helpless_target_gains_plus4() -> None: a, t = eng._states _add_condition(t, "helpless") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) - # roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 (Dex denied). + # roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 minus + # the Dex-0 penalty (helpless target has effective Dexterity 0: -5). assert result.total == 19 - assert result.ac == 10 + assert result.ac == 5 assert result.hit is True @@ -180,7 +181,7 @@ def test_melee_vs_helpless_plus4_applies_to_crit_confirmation() -> None: a, t = eng._states _add_condition(t, "helpless") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) - # Confirm 10 + 5 + 4 = 19 >= 10 -> crit. + # Confirm 10 + 5 + 4 = 19 >= 5 (flat-footed 10 - 5 Dex-0) -> crit. assert result.crit is True assert result.damage == 8 diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py index c69a16f..61a6cf8 100644 --- a/tests/test_conditions_effects.py +++ b/tests/test_conditions_effects.py @@ -189,13 +189,14 @@ class TestStaggeredActionEconomy: assert result is False -# ── Test 4: nauseated → single standard action only ─────────────────────────── +# ── Test 4: nauseated → single move action only ─────────────────────────────── class TestNauseatedActionEconomy: """Given: a nauseated combatant When: _take_turn is called - Then: they can only take standard actions (no full attack, no charge, no move).""" + Then: they can only take a single move action (no standard, no attack, + no spellcasting — CRB nauseated).""" def test_nauseated_no_attack_and_no_full_round(self) -> None: hero = _make_hero(bab=6) @@ -241,7 +242,7 @@ class TestParalyzedNoActions: assert found_wait, "paralyzed combatant should log 'wait (cannot act)'" def test_paralyzed_defender_helpless_ac(self) -> None: - """Paralyzed target: denies_dex_to_ac=True (flat-footed) + -4 AC (helpless).""" + """Paralyzed is helpless: denies_dex_to_ac (flat-footed) + Dex 0 (-5).""" hero = _make_hero() foe = _make_foe() engine, h, f = _make_engine(hero, foe, [10, 4]) @@ -249,8 +250,10 @@ class TestParalyzedNoActions: ActiveCondition(condition=PARALYZED, rounds_left=5, source="test") ) result = engine.resolve_attack(h, f, hero.attacks[0]) - # Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9 - assert result.ac == 9 + # Paralyzed: denies_dex_to_ac → flat_footed AC 13; helpless Dex 0 → -5; + # melee attacker also gains the +4 helpless bonus (roll 10 + 5 + 4 = 19). + assert result.ac == 8 + assert result.total == 19 # ── Test 6: StatModifier condition filtering ────────────────────────────────── diff --git a/tests/test_conformance_p1.py b/tests/test_conformance_p1.py new file mode 100644 index 0000000..70a24a8 --- /dev/null +++ b/tests/test_conformance_p1.py @@ -0,0 +1,438 @@ +"""P1 PF1e conformance fixes (Oracle-adjudicated audit). + +Six genuine RAW deviations, one section per bug: + +B1 Area saves: every creature in an area rolls the save the SPELL lists + (Will / Fortitude / Reflex); "save negates" areas save, and secondary + creatures are not hardcoded to Reflex. +B2 Concentration: d20 + caster level + the casting ability modifier + (Int/Wis/Cha), never Constitution; Combat Casting's +4 only applies + when casting defensively or while grappled. +B3 Prone: the prone attacker takes -4 on melee attacks (ranged treated as + crossbow-exempt at -4); the prone defender takes -4 AC vs melee and + gains +4 AC vs ranged. +B4 Spell slots: casting without an available slot is refused; cantrips + (level 0) cast freely. +B5 Helpless: paralyzed / asleep / unconscious (and helpless) are helpless — + melee attackers gain +4 and the target has effective Dexterity 0 (-5); + stunned is NOT helpless. +B6 Nauseated: only a single move action per turn — no standard actions, no + attacks, no spellcasting, no concentration. + +B2 is covered in tests/test_concentration.py; B1's per-spell cases in +tests/test_spells.py (TestAreaSavesPerSpellSaveType). This file adds the +cross-cutting conformance locks. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from pf1e_simulator.combat import ( + Action, + ActionResult, + CombatantState, + CombatEngine, + _nauseated_filter, +) +from pf1e_simulator.conditions import ( + ASLEEP, + CONDITIONS_BY_NAME, + PARALYZED, + Condition, +) +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 ( + AreaEffect, + ConditionEffect, + DamageEffect, + SpellSpec, +) + +PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] + + +def _melee(cid: str) -> AttackSpec: + return AttackSpec( + id=f"{cid}-sword", + name="short sword", + kind="melee", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + ) + + +def _bow(cid: str) -> AttackSpec: + return AttackSpec( + id=f"{cid}-bow", + name="short bow", + kind="ranged", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], + range_increment_ft=60, + ) + + +def _combatant( + cid: str, + *, + level: int = 1, + hp: int = 100, + ac: int = 15, + dex: int = 10, + attacks: tuple[AttackSpec, ...] = (), + spells: tuple[str, ...] = (), + spell_slots: dict[int, int] | None = None, +) -> Combatant: + return Combatant( + id=cid, + name=cid, + level=level, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=dex, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=hp, + ac=ACProfile(total=ac, touch=ac, flat_footed=ac), + bab=1, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=0, ref=0, will=0), + attacks=list(attacks) or [_melee(cid)], + spells=list(spells), + spell_slots=spell_slots or {}, + ) + + +def _engine( + combatants: list[Combatant], + positions: list[tuple[int, int]], + rng: list[int], + spell_registry: dict[str, SpellSpec] | None = None, + policy: PolicyFn | None = None, +) -> CombatEngine: + sides = ["players"] + ["monsters"] * (len(combatants) - 1) + states = [ + CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max) + for c, side, pos in zip(combatants, sides, positions, strict=True) + ] + legend = {".": TerrainType(type="floor", move_cost=1)} + spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) + grid = Grid.from_spec(spec) + return CombatEngine( + ScriptedRng(rng), grid, states, + spell_registry=spell_registry or {}, policy=policy, + ) + + +def _add_condition(state: CombatantState, cond: Condition) -> None: + state.active_conditions.append( + ActiveCondition(condition=cond, rounds_left=5, source="test") + ) + + +# ── B1: point-cast area — every creature rolls the spell's save type ───────── + + +_SLUMBER_CLOUD = SpellSpec( + name="Slumber Cloud", + level=1, + school="enchantment", + range="close", + save="will", + effects=( + AreaEffect( + radius_ft=20, + effects=(ConditionEffect("asleep", 10, save_negates=True),), + save_for_half=False, + ), + ), +) + + +def test_point_cast_area_rolls_the_spells_save_per_creature() -> None: + # No primary target: each creature in the burst rolls its own Will save + # (the old code short-circuited save_for_half=False to "no save at all"). + eng = _engine( + [ + _combatant("caster", spell_slots={1: 1}), + _combatant("sleeper"), + _combatant("waker"), + ], + [(0, 7), (0, 2), (0, 3)], + [1, 20], + spell_registry={"Slumber Cloud": _SLUMBER_CLOUD}, + ) + caster, sleeper, waker = eng._states + result = eng._execute( + caster, + Action(kind="cast_spell", spell_name="Slumber Cloud", target_pos=(0, 2)), + ) + assert result.ok + # Caster is outside the 4-cell burst; sleeper fails its Will (d20 1), + # waker saves on natural 20 — the negate semantics now reach areas. + assert not any(ac.condition.name == "asleep" for ac in caster.active_conditions) + assert any(ac.condition.name == "asleep" for ac in sleeper.active_conditions) + assert not any(ac.condition.name == "asleep" for ac in waker.active_conditions) + + +# ── B3: prone rules by weapon kind ─────────────────────────────────────────── + + +def test_prone_attacker_minus4_on_melee_attack() -> None: + eng = _engine([_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4]) + a, d = eng._states + _add_condition(a, CONDITIONS_BY_NAME["prone"]) + result = eng.resolve_attack(a, d, a.combatant.attacks[0]) + # roll 10 + bonus 5 - 4 (prone melee) = 11. + assert result.total == 11 + + +def test_prone_defender_minus4_ac_vs_melee_plus4_vs_ranged() -> None: + melee_eng = _engine( + [_combatant("atk"), _combatant("def")], [(0, 0), (0, 1)], [10, 4] + ) + _add_condition(melee_eng._states[1], CONDITIONS_BY_NAME["prone"]) + melee = melee_eng.resolve_attack( + melee_eng._states[0], melee_eng._states[1], _melee("atk") + ) + assert melee.ac == 11 # 15 - 4 vs melee + + ranged_eng = _engine( + [_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")], + [(0, 0), (0, 3)], + [10, 4], + ) + _add_condition(ranged_eng._states[1], CONDITIONS_BY_NAME["prone"]) + ranged = ranged_eng.resolve_attack( + ranged_eng._states[0], ranged_eng._states[1], _bow("atk") + ) + assert ranged.ac == 19 # 15 + 4 vs ranged + + +def test_prone_ranged_attacker_minus4_crossbow_exemption_documented() -> None: + # The model cannot tell a crossbow from a bow: every ranged weapon is + # treated as crossbow-exempt — usable, but at -4 while prone. + eng = _engine( + [_combatant("atk", attacks=(_bow("atk"),)), _combatant("def")], + [(0, 0), (0, 3)], + [15, 4], + ) + _add_condition(eng._states[0], CONDITIONS_BY_NAME["prone"]) + result = eng.resolve_attack( + eng._states[0], eng._states[1], _bow("atk") + ) + assert result.total == 16 # 15 + 5 - 4 + + +# ── B4: spell slots enforced ───────────────────────────────────────────────── + + +_ZAP = SpellSpec( + name="Zap", + level=1, + school="evocation", + range="close", + save="none", + effects=(DamageEffect(formula=parse_dice("1d4"), types=("electricity",)),), +) + +_SPARK = SpellSpec( + name="Spark", + level=0, + school="evocation", + range="close", + save="none", + effects=(DamageEffect(formula=parse_dice("1d3"), types=("electricity",)),), +) + + +def test_cast_without_a_slot_is_refused() -> None: + eng = _engine( + [_combatant("caster", spell_slots={1: 1}), _combatant("tgt")], + [(0, 0), (0, 2)], + [3, 3], + spell_registry={"Zap": _ZAP}, + ) + caster, tgt = eng._states + eng._cast_spell(caster, tgt, "Zap") + assert tgt.hp == 97 # first cast spent the only slot (1d4 → 3) + eng._cast_spell(caster, tgt, "Zap") + assert tgt.hp == 97 # second cast refused before any roll + assert any("NO SPELL SLOT" in line for line in eng._transcript) + assert caster.spell_slots_left[1] == 0 + + +def test_cantrips_cast_freely_without_slots() -> None: + eng = _engine( + [_combatant("caster"), _combatant("tgt")], + [(0, 0), (0, 2)], + [3, 3, 3], + spell_registry={"Spark": _SPARK}, + ) + caster, tgt = eng._states + for _ in range(3): + eng._cast_spell(caster, tgt, "Spark") + assert tgt.hp == 100 - 9 # three casts of 1d3 → 3 each + assert not any("NO SPELL SLOT" in line for line in eng._transcript) + + +# ── B5: helpless extended to paralyzed / asleep / unconscious ──────────────── + + +def _helpless_cases() -> list[Condition]: + return [ + CONDITIONS_BY_NAME["helpless"], + PARALYZED, + ASLEEP, + Condition(name="unconscious", denies_dex_to_ac=True, cant_act=True), + ] + + +def test_melee_vs_helpless_states_gains_plus4_and_dex_zero() -> None: + for cond in _helpless_cases(): + eng = _engine( + [_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4] + ) + _add_condition(eng._states[1], cond) + result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) + # roll 10 + 5 + 4 (helpless melee) = 19; AC 16 (flat-footed, Dex + # denied) - 5 (effective Dex 0) = 11. + assert result.total == 19, cond.name + assert result.ac == 11, cond.name + + +def test_stunned_is_not_helpless() -> None: + eng = _engine( + [_combatant("atk"), _combatant("def", ac=16)], [(0, 0), (0, 1)], [10, 4] + ) + _add_condition(eng._states[1], CONDITIONS_BY_NAME["stunned"]) + result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) + # No +4 to hit (stunned is not helpless); AC = flat-footed 16 - 2 (the + # stunned condition's own AC penalty), no Dex-0 -5. + assert result.total == 15 + assert result.ac == 14 + + +def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None: + # A Dex 8 (-1) helpless target: the -1 penalty gives way to the Dex-0 -5 + # (adjustment -5 - (-1) = -4 on top of the flat-footed base). + eng = _engine( + [_combatant("atk"), _combatant("def", ac=14, dex=8)], + [(0, 0), (0, 1)], + [10, 4], + ) + _add_condition(eng._states[1], PARALYZED) + result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) + assert result.ac == 10 # 14 - 4 + + +# ── B6: nauseated — single move action only, no casting ────────────────────── + + +def test_nauseated_cannot_take_a_standard_attack() -> None: + def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: + if s.combatant.id == "hero": + return (Action(kind="attack", target_id="foe"),) + return (Action(kind="wait"),) + + eng = _engine( + [_combatant("hero"), _combatant("foe")], [(0, 0), (0, 1)], [10, 5], + policy=policy, + ) + hero, _foe = eng._states + _add_condition(hero, CONDITIONS_BY_NAME["nauseated"]) + eng._roll_initiative() + eng._take_turn(hero) + assert not any( + " vs foe" in line and "short sword" in line for line in eng._transcript + ) + assert hero.last_action_results == [ + ActionResult(kind="attack", ok=False, reason="downgraded:nauseated") + ] + + +def test_nauseated_keeps_only_the_first_move_action() -> None: + def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: + if s.combatant.id == "hero": + return ( + Action(kind="move", target_id="foe"), + Action(kind="attack", target_id="foe"), + ) + return (Action(kind="wait"),) + + eng = _engine( + [_combatant("hero"), _combatant("foe")], [(0, 0), (0, 3)], [10, 5], + policy=policy, + ) + hero, _foe = eng._states + _add_condition(hero, CONDITIONS_BY_NAME["nauseated"]) + eng._roll_initiative() + eng._take_turn(hero) + assert any("hero: move" in line for line in eng._transcript) + assert hero.last_action_results == [ + ActionResult(kind="attack", ok=False, reason="downgraded:nauseated"), + ActionResult(kind="move", ok=True, reason="ok"), + ] + + +def test_nauseated_cannot_cast_via_action_execution() -> None: + # Defense in depth: even a cast that bypasses the action filter (a ready + # payload, say) is refused while nauseated. + eng = _engine( + [_combatant("caster", spell_slots={1: 9}), _combatant("tgt")], + [(0, 0), (0, 2)], + [], + spell_registry={"Zap": _ZAP}, + ) + caster, tgt = eng._states + _add_condition(caster, CONDITIONS_BY_NAME["nauseated"]) + eng._execute_cast_spell(caster, tgt, "Zap") + assert tgt.hp == 100 + assert caster.spell_slots_left[1] == 9 + assert any( + "cast_spell rejected (cannot attack)" in line for line in eng._transcript + ) + + +def test_nauseated_filter_keeps_move_kinds_drops_everything_else() -> None: + for kind in ("move", "5foot_step", "stand_up", "drop_prone"): + kept = _nauseated_filter((Action(kind=kind, target_id="foe"),)) + assert len(kept) == 1, kind + assert kept[0].kind == kind, kind + for kind in ("attack", "cast_spell", "full_attack", "charge", "maneuver", "hex"): + assert _nauseated_filter((Action(kind=kind, target_id="foe"),)) == (), kind + + +# ── B3/B5 engine-side locks (unit level) ──────────────────────────────────── + + +def test_stat_modifiers_no_longer_flatten_prone_or_paralyzed_ac() -> None: + # The unconditional -4 AC appends for prone/paralyzed are gone from + # _stat_modifiers: the adjustments are weapon-kind dependent and live in + # resolve_attack. A prone + paralyzed defender's collected "ac" mods + # carry no -4. + eng = _engine([_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], []) + _add_condition(eng._states[1], CONDITIONS_BY_NAME["prone"]) + _add_condition(eng._states[1], PARALYZED) + mods = eng._stat_modifiers(eng._states[1], "ac") + assert all(m.value != -4 for m in mods) + + +def test_asleep_condition_no_longer_carries_a_flat_ac_penalty() -> None: + # The ASLEEP -4 AC approximation is superseded by the engine's Dex-0 + # adjustment for helpless targets. + assert ASLEEP.modifiers == () diff --git a/tests/test_immediate_actions.py b/tests/test_immediate_actions.py index eeeb5b7..43ae7c2 100644 --- a/tests/test_immediate_actions.py +++ b/tests/test_immediate_actions.py @@ -312,6 +312,7 @@ def test_damage_taken_reaction_fires() -> None: caster = make_combatant("caster", hp=50) reactor = make_combatant("reactor", hp=50, kind="ranged") caster_state = make_state(caster, "players", (1, 1)) + caster_state.spell_slots_left = {1: 9} reactor_state = make_state(reactor, "monsters", (1, 3)) states = [caster_state, reactor_state] diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 938bc61..a3b81ae 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -57,6 +57,7 @@ def make_combatant( features: list[AbilitySpec] | None = None, spells: list[str] | None = None, ranged: bool = False, + spell_slots: dict[int, int] | None = None, ) -> Combatant: """Build a single-weapon combatant with known static stats. @@ -103,6 +104,7 @@ def make_combatant( attacks=[attack], features=features or [], spells=spells or [], + spell_slots=spell_slots or {}, ) diff --git a/tests/test_point_targeting.py b/tests/test_point_targeting.py index 1729dfb..501b86e 100644 --- a/tests/test_point_targeting.py +++ b/tests/test_point_targeting.py @@ -108,7 +108,9 @@ def _cast_scene( (distance 0) but not foe_out (distance 5) nor the caster (distance 9). """ caster = make_state( - make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0) + make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0) ) foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (0, 4)) foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (4, 7)) @@ -187,7 +189,9 @@ def test_point_cast_without_line_of_effect_rejected() -> None: When: cast_spell at that unseen point Then: invalid_destination rejection without casting.""" caster = make_state( - make_combatant("caster", hp=100, spells=["Fireball"]), "players", (0, 0) + make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (0, 0) ) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 6)) eng = _engine([caster, foe], [], grid=_walled_grid()) @@ -216,7 +220,9 @@ def test_point_cast_rolls_a_save_per_creature_in_area() -> None: When: a target-less point cast at (0,4) Then: no primary save is rolled (queue would shift otherwise) and EACH creature rolls its own: foe_a fails (2) full 24, foe_b saves (20) half 12.""" - caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + caster = make_state(make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0)) foe_a = make_state(make_combatant("foe_a", hp=100), "monsters", (0, 4)) foe_b = make_state(make_combatant("foe_b", hp=100), "monsters", (0, 5)) eng = _engine([caster, foe_a, foe_b], [2] + [3] * 8 + [20] + [3] * 8) @@ -233,7 +239,9 @@ def test_point_cast_no_save_area_spell_rolls_damage_dice_only() -> None: When: a target-less point cast Then: no saving throw is rolled at all — only the 8 damage dice.""" caster = make_state( - make_combatant("caster", hp=100, spells=["Spark Burst"]), "players", (7, 0) + make_combatant( + "caster", hp=100, spells=["Spark Burst"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0) ) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) eng = _engine([caster, foe], [3] * 8) @@ -254,7 +262,9 @@ def test_creature_centered_cast_transcript_pinned() -> None: When: cast_spell with target_id and NO target_pos (the legacy path) Then: the transcript is the pinned creature-centered line — primary save rolled once, reused for the target in the area, 8d6 all 3s.""" - caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + caster = make_state(make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) eng = _engine([caster, foe], [2] + [3] * 8) result = eng._execute( @@ -276,13 +286,17 @@ def test_target_pos_at_target_cell_matches_creature_centered() -> None: target_pos set to the target's own cell (target_id kept) When: both casts resolve with identical scripted rolls Then: transcripts and hp outcomes are byte-identical.""" - caster = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + caster = make_state(make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) plain = _engine([caster, foe], [2] + [3] * 8) plain._execute( caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball") ) - caster2 = make_state(make_combatant("caster", hp=100, spells=["Fireball"]), "players", (7, 0)) + caster2 = make_state(make_combatant( + "caster", hp=100, spells=["Fireball"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (7, 0)) foe2 = make_state(make_combatant("foe", hp=100), "monsters", (0, 4)) pointed = _engine([caster2, foe2], [2] + [3] * 8) pointed._execute( @@ -304,7 +318,9 @@ def test_non_area_spell_with_target_pos_resolves_on_target() -> None: Then: target_pos is ignored — the missile hits its creature (1d4+1 = 4) and the transcript is the ordinary creature-centered line.""" caster = make_state( - make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0) + make_combatant( + "caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (0, 0) ) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2)) eng = _engine([caster, foe], [3]) @@ -325,7 +341,9 @@ def test_non_area_point_cast_without_target_is_harmless_no_op() -> None: When: the action is dispatched with an empty roll queue Then: nothing crashes, nothing is cast, no die is rolled.""" caster = make_state( - make_combatant("caster", hp=100, spells=["Magic Missile"]), "players", (0, 0) + make_combatant( + "caster", hp=100, spells=["Magic Missile"], spell_slots={1: 9, 2: 9, 3: 9} + ), "players", (0, 0) ) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2)) eng = _engine([caster, foe], []) diff --git a/tests/test_prone_actions.py b/tests/test_prone_actions.py index 27710af..2994e19 100644 --- a/tests/test_prone_actions.py +++ b/tests/test_prone_actions.py @@ -7,8 +7,10 @@ removes the ``prone`` condition from BOTH representations — the duration-track ``conditions`` list (how ``drop_prone`` applies it). ``drop_prone`` is a free action that goes prone until the combatant stands up. Rejections: standing up while not prone → ``not_prone``; dropping while already prone → -``already_prone``. The engine's existing prone behaviour (flat -4 AC in -``_stat_modifiers``, -4 ranged attacks) is unchanged. +``already_prone``. The engine's prone modifiers follow the CRB (B3 +conformance): a prone attacker takes -4 on melee attacks (ranged weapons are +treated as crossbow-exempt — usable at -4, the model cannot tell them apart); +a prone defender takes -4 AC vs melee and gains +4 AC vs ranged. """ from __future__ import annotations diff --git a/tests/test_spell_resistance.py b/tests/test_spell_resistance.py index 9e0c06f..8f9dc9f 100644 --- a/tests/test_spell_resistance.py +++ b/tests/test_spell_resistance.py @@ -24,6 +24,7 @@ def _make_combatant( spell_resistance: int = 0, resistances: dict[str, int] | None = None, immunities: tuple[str, ...] = (), + spell_slots: dict[int, int] | None = None, ) -> Combatant: return Combatant( id=id_, @@ -43,6 +44,7 @@ def _make_combatant( spell_resistance=spell_resistance, resistances=resistances or {}, immunities=immunities, + spell_slots=spell_slots or {}, ) @@ -102,7 +104,7 @@ _FIREBALL = SpellSpec( def test_sr_blocks_all_effects() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) target = _make_combatant("target", spell_resistance=15) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], @@ -116,7 +118,7 @@ def test_sr_blocks_all_effects() -> None: def test_sr_passes_when_cl_suffices() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) target = _make_combatant("target", spell_resistance=10) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], @@ -130,7 +132,7 @@ def test_sr_passes_when_cl_suffices() -> None: def test_per_target_sr_in_area_spell() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) t1 = _make_combatant("t1", spell_resistance=15) t2 = _make_combatant("t2", spell_resistance=0) eng = _engine( @@ -149,7 +151,7 @@ def test_per_target_sr_in_area_spell() -> None: def test_no_sr_check_when_sr_zero() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) target = _make_combatant("target", spell_resistance=0) eng = _engine( [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], @@ -164,7 +166,7 @@ def test_no_sr_check_when_sr_zero() -> None: def test_fire_resistance_reduces_spell_damage() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) target = _make_combatant( "target", spell_resistance=0, @@ -182,7 +184,7 @@ def test_fire_resistance_reduces_spell_damage() -> None: def test_fire_immunity_negates_damage() -> None: - caster = _make_combatant("caster", spell_resistance=0) + caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9}) target = _make_combatant( "target", spell_resistance=0, diff --git a/tests/test_spells.py b/tests/test_spells.py index f128598..772cf0c 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -73,6 +73,9 @@ def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant: saves=Saves(fort=3, ref=4, will=4), attacks=[attack], casting_ability="int", + # B4 conformance: _cast_spell now refuses without a slot; test + # casters carry a generous pool so fixtures stay about spells. + spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99}, ) @@ -260,6 +263,64 @@ class TestFireballSave: assert any("SAVED" in line for line in engine._transcript) +class TestAreaSavesPerSpellSaveType: + """B1 (PF1e conformance): every creature in an area rolls the save the + SPELL lists (Will/Fort/Ref) — "save negates" areas are no longer + save-free, and secondary creatures do not always roll Reflex.""" + + def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None: + # Sleep (Will negates, save_for_half=False): primary target saves on + # natural 20 (first roll), the caster — second creature in the area — + # rolls its own Will and fails on natural 1 (second roll). + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine( + caster, target, [20, 1], target_pos=(2, 0) + ) + engine._cast_spell(c, t, "Sleep") + assert not any(ac.condition.name == "asleep" for ac in t.active_conditions) + assert any(ac.condition.name == "asleep" for ac in c.active_conditions) + + def test_color_spray_primary_save_is_not_discarded(self) -> None: + # Color Spray (Will negates): the primary's pre-rolled save decides — + # natural 1 fails (stunned), the caster's own natural 20 succeeds. + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine( + caster, target, [1, 20], target_pos=(2, 0) + ) + engine._cast_spell(c, t, "Color Spray") + assert any(ac.condition.name == "stunned" for ac in t.active_conditions) + assert not any(ac.condition.name == "stunned" for ac in c.active_conditions) + + def test_web_secondary_creature_rolls_reflex(self) -> None: + # Web (Reflex negates): secondary creature rolls its own Reflex. + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine( + caster, target, [20, 1], target_pos=(2, 0) + ) + engine._cast_spell(c, t, "Web") + assert not any(ac.condition.name == "grappled" for ac in t.active_conditions) + assert any(ac.condition.name == "grappled" for ac in c.active_conditions) + + def test_sound_burst_secondary_rolls_fortitude_not_reflex(self) -> None: + # Sound Burst (Fortitude partial): caster has ref +4, fort +3 and the + # DC is 16 — d20 12 passes Reflex but fails Fortitude. The caster + # being stunned proves the FORT save was rolled (B1's hardcoded ref + # would have "saved" and left the caster unstunned). + caster = _make_caster() + target = _make_target(hp_max=200) + # rolls: primary (target) fort 20 = save; caster fort 12 = fail; + # then the 1d8 damage dice for each creature in state order. + engine, c, t = _make_engine( + caster, target, [20, 12, 3, 3], target_pos=(2, 0) + ) + engine._cast_spell(c, t, "Sound Burst") + assert any(ac.condition.name == "stunned" for ac in c.active_conditions) + assert t.hp == 200 - 1 # saved: 3 // 2 + + class TestTargetedSpellSaveNegates: """Given: a targeted spell with a save (half_on_save=False) When: the target succeeds / fails its save