fix(rules): correct P1 PF1e conformance bugs (area saves, concentration, prone, slots, helpless, nauseated)

This commit is contained in:
2026-09-10 20:52:28 +02:00
parent 1ca5aed758
commit 5afe495c4a
15 changed files with 790 additions and 86 deletions
+40 -15
View File
@@ -350,8 +350,12 @@ Règles modélisées :
- Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant - Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant
ajoute `prone` à la liste `conditions` **sans durée**, donc la condition ajoute `prone` à la liste `conditions` **sans durée**, donc la condition
persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent
`active_conditions` avec 1 round). Les modificateurs de `prone` (−4 CA, −4 aux `active_conditions` avec 1 round). Les modificateurs de `prone` s'appliquent
attaques à distance) s'appliquent immédiatement. Si la condition est déjà 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 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 cible. `default_policy` ne la choisit pas (disponible via une politique
personnalisée). personnalisée).
@@ -555,15 +559,19 @@ Règles modélisées :
condition inconnue ou non supportée reste exclue (False). condition inconnue ou non supportée reste exclue (False).
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis - Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
l'emplacement du niveau concerné (`spend_spell_slot`). 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 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 est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
(`test_dr_does_not_reduce_spell_damage`). Incanter dans une (`test_dr_does_not_reduce_spell_damage`). Incanter dans une
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un 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`) : 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é, `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 sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de
(+4 avec le don Combat Casting). Un sort ciblé avec sauvegarde fait pleins dégâts 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 sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
@@ -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). 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 L'invisibilité est symétrique : subi comme cible, la condition accorde le
concealment total de 50 % ci-dessus. concealment total de 50 % ci-dessus.
- Cible inerme (helpless, V5a) : les attaques de **mêlée** contre une cible - Cible inerme (helpless, V5a + conformance P1) : sont traitées comme
`helpless` gagnent **+4 au jet d'attaque et à la confirmation** (aucun bonus à **inertes** les conditions `helpless`, `paralyzed`, `asleep` et
distance) ; `helpless` porte `denies_dex_to_ac` (CA flat-footed) + `cant_act`. `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 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 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`). 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 - Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, 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 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 ; 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 la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible
50 %) impose la 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. 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 Inerme (`helpless`, `paralyzed`, `asleep`, `unconscious`) : ne peut pas agir,
contre lui (voir « Cible inerme »). Fasciné (`fascinated`) : 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 Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex
de la cible (voir « Attaquant invisible »). de la cible (voir « Attaquant invisible »).
Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage` 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` - 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à (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 à 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, zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
ne font que la moitié des dégâts. 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 - 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 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 lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
+120 -34
View File
@@ -101,6 +101,7 @@ from pf1e_simulator.spells import (
ConditionEffect, ConditionEffect,
DamageEffect, DamageEffect,
HealEffect, HealEffect,
SaveType,
SpellEffect, SpellEffect,
SummonEffect, SummonEffect,
save_descriptors_for_effects, save_descriptors_for_effects,
@@ -166,6 +167,13 @@ _CONFUSED_BABBLE_MAX = 50
_CONFUSED_SELF_DAMAGE_MAX = 75 _CONFUSED_SELF_DAMAGE_MAX = 75
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker _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 _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_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) _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 _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) 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( def _modifier_condition_holds(
self, self,
_state: CombatantState, _state: CombatantState,
@@ -1805,11 +1823,9 @@ class CombatEngine:
if m.target == target if m.target == target
and (m.weapon_filter is None or m.weapon_filter == weapon_name) and (m.weapon_filter is None or m.weapon_filter == weapon_name)
) )
if target == "ac": # PF1e: prone and helpless (paralyzed/asleep/unconscious) AC
if self._has_condition_named(state, "prone"): # adjustments depend on the weapon kind and are applied in
mods.append(StatModifier(target="ac", value=-4)) # resolve_attack, where it is known — not unconditionally here.
if self._has_condition_named(state, "paralyzed"):
mods.append(StatModifier(target="ac", value=-4))
return [ return [
m m
for m in mods for m in mods
@@ -1901,6 +1917,29 @@ class CombatEngine:
return 0 return 0
return max(1, attacker.combatant.bab // 4 + 1) 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( def resolve_attack(
self, self,
attacker: CombatantState, attacker: CombatantState,
@@ -1929,14 +1968,21 @@ class CombatEngine:
atk_mods = self._stat_modifiers( atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft 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)) atk_mods.append(StatModifier(target="attack", value=-4))
# PF1e: an invisible attacker strikes from an unknown direction (+2 on # PF1e: an invisible attacker strikes from an unknown direction (+2 on
# attack and crit confirmation, target loses its Dex bonus to AC). # attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"): if self._has_condition_named(attacker, "invisible"):
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS)) atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e: melee attacks against a helpless target gain +4 (ranged none). # PF1e (CRB): melee attacks against a helpless target gain +4; the
if weapon.kind == "melee" and self._has_condition_named(target, "helpless"): # 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)) atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
attack_mod = resolve_modifiers(atk_mods) attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 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 base_ac = target.combatant.ac.touch
else: else:
base_ac = full_ac_base 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.defensive_ac_bonus
ac += target.aid_ac_bonus ac += target.aid_ac_bonus
occupied = frozenset( occupied = frozenset(
@@ -2609,17 +2656,19 @@ class CombatEngine:
) -> bool: ) -> bool:
"""Concentration check: DC depends on situation. """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) dc = self._concentration_dc(caster, spell_level, damage_taken)
has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features) injured_while_casting = damage_taken > 0 and caster.grappled_with is None
bonus = 4 if has_combat_casting else 0 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() roll = self._rng.d20()
# Use caster level + ability mod for concentration check total = (
caster_level = caster.combatant.level roll + caster.combatant.level + self._casting_ability_mod(caster) + bonus
# 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
return total >= dc return total >= dc
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool: 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.""" """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 """Cast the carried spell; an explicit ``target_pos`` (U7b) is
validated first — an off-map point or one without line of effect validated first — an off-map point or one without line of effect
from the caster is rejected without casting.""" from the caster is rejected without casting."""
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: if target_pos is not None:
rejection = self._point_target_rejection(state, target_pos) rejection = self._point_target_rejection(state, target_pos)
if rejection is not None: if rejection is not None:
@@ -4142,12 +4199,15 @@ class CombatEngine:
return return
self._charge(state, target, result.weapon) self._charge(state, target, result.weapon)
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int: def _casting_ability_mod(self, caster: CombatantState) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier.""" """Modifier of the casting ability (the one used for bonus spells)."""
ability_name = caster.combatant.casting_ability.lower() ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2 return (ability_score - 10) // 2
return 10 + spell.level + ability_mod
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]: def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
"""One per-spell dict for the Observation, enriched from the registry.""" """One per-spell dict for the Observation, enriched from the registry."""
@@ -4208,10 +4268,18 @@ class CombatEngine:
caster, target, spell, spell_name, center_pos, has_area=has_area caster, target, spell, spell_name, center_pos, has_area=has_area
): ):
return 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) # Check for AoO from casting in threatened square (before resolving spell)
if self._check_casting_aoo(caster, spell.level): if self._check_casting_aoo(caster, spell.level):
return return
caster.spend_spell_slot(spell.level)
if ( if (
target is not None target is not None
and not has_area and not has_area
@@ -4311,7 +4379,7 @@ class CombatEngine:
caster=caster, spell_name=spell.name, caster=caster, spell_name=spell.name,
empower="Empower Spell" in metamagic_feats, empower="Empower Spell" in metamagic_feats,
maximize="Maximize 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) save_str = _save_label(save_result)
if target is None and center_pos is not None: if target is None and center_pos is not None:
@@ -4349,7 +4417,7 @@ class CombatEngine:
"""Legacy wrapper for compatibility.""" """Legacy wrapper for compatibility."""
return self._apply_spell_effects_from_list( return self._apply_spell_effects_from_list(
spell.effects, target, save_result, caster_live, target_live, 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( def _apply_spell_effects_from_list(
@@ -4366,6 +4434,7 @@ class CombatEngine:
maximize: bool = False, maximize: bool = False,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> list[str]: ) -> list[str]:
saved = save_result is not None and save_result.success saved = save_result is not None and save_result.success
return [ return [
@@ -4373,7 +4442,7 @@ class CombatEngine:
effect, target, caster_live, target_live, effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name, caster=caster, spell_name=spell_name,
empower=empower, maximize=maximize, saved=saved, 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 for effect in effects
] ]
@@ -4392,13 +4461,14 @@ class CombatEngine:
saved: bool, saved: bool,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> str: ) -> str:
if isinstance(effect, AreaEffect): if isinstance(effect, AreaEffect):
return self._apply_area_effect( return self._apply_area_effect(
effect, target, caster_live, target_live, effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name, saved=saved, caster=caster, spell_name=spell_name, saved=saved,
empower=empower, maximize=maximize, dc=dc, empower=empower, maximize=maximize, dc=dc,
center_pos=center_pos, center_pos=center_pos, save_type=save_type,
) )
if isinstance(effect, SummonEffect): if isinstance(effect, SummonEffect):
return self._apply_summon_effect(effect, caster, caster_live, spell_name) return self._apply_summon_effect(effect, caster, caster_live, spell_name)
@@ -4535,6 +4605,7 @@ class CombatEngine:
maximize: bool = False, maximize: bool = False,
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> str: ) -> str:
"""Apply an area effect to all creatures within radius. """Apply an area effect to all creatures within radius.
@@ -4558,7 +4629,8 @@ class CombatEngine:
if not self._check_spell_resistance(caster, affected_state): if not self._check_spell_resistance(caster, affected_state):
continue continue
creature_saved = self._area_save( 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: for sub_effect in effect.effects:
target_live = self._stats[affected_state.combatant.id] target_live = self._stats[affected_state.combatant.id]
@@ -4603,19 +4675,27 @@ class CombatEngine:
*, *,
saved: bool, saved: bool,
dc: int = 15, dc: int = 15,
save_type: SaveType = "none",
) -> bool: ) -> bool:
"""Save result for one creature in an area effect. """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 The resolved primary target re-uses its already-rolled save; every
other creature in the area — and every creature of a point-targeted other creature in the area — and every creature of a point-targeted
burst (no primary target, U7b) — rolls its own. burst (no primary target, U7b) — rolls its own.
""" """
if not effect.save_for_half: if save_type == "none":
return False return False
if affected_state is target: if affected_state is target:
return saved return saved
save_result = self.resolve_save( 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 return save_result.success
@@ -5277,10 +5357,12 @@ def _choose_spell_action(
_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"}) _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"}) _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, ...]: 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, ...]: 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: for action in actions:
if action.kind in _STANDARD_KINDS: if action.kind in _NAUSEA_MOVE_KINDS:
return (action,) return (action,)
return () return ()
+7 -5
View File
@@ -205,12 +205,11 @@ DAZED = Condition(
cant_act=True, 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( ASLEEP = Condition(
name="asleep", name="asleep",
modifiers=(
StatModifier(target="ac", value=-4),
),
denies_dex_to_ac=True, denies_dex_to_ac=True,
cant_act=True, cant_act=True,
cant_move=True, cant_move=True,
@@ -218,7 +217,10 @@ ASLEEP = Condition(
# Helpless: helpless for all intents, treated as Dex 0 (loses Dex to AC), # 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( HELPLESS = Condition(
name="helpless", name="helpless",
denies_dex_to_ac=True, denies_dex_to_ac=True,
+2 -2
View File
@@ -160,8 +160,8 @@ def test_staggered_full_attack_downgrade_recorded_with_ok_attack() -> None:
def test_nauseated_full_attack_downgrade_recorded() -> None: def test_nauseated_full_attack_downgrade_recorded() -> None:
# Nauseated: full_attack is not a standard action → the filter empties the # Nauseated (B6): only move-like actions survive the filter — full_attack
# sequence; only the synthetic downgrade result remains. # is dropped; only the synthetic downgrade result remains.
eng = _engine( eng = _engine(
[10, 5], [10, 5],
policy=_fixed_policy(Action(kind="full_attack", target_id="foe")), policy=_fixed_policy(Action(kind="full_attack", target_id="foe")),
+67 -5
View File
@@ -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 - grappled or pinned: DC = 10 + grappler's CMB + spell_level
- casting defensively: DC = 15 + 2 * spell_level - casting defensively: DC = 15 + 2 * spell_level
The check roll = d20 + caster_level + casting ability mod (+4 Combat Casting), The check roll = d20 + caster level + the casting ability modifier used to
unchanged from the current logic. 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 __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
@@ -32,6 +35,11 @@ def _combatant(
level: int = 5, level: int = 5,
con: int = 12, con: int = 12,
cmb: int | None = None, 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: ) -> Combatant:
attack = AttackSpec( attack = AttackSpec(
id=f"{cid}-w", id=f"{cid}-w",
@@ -40,6 +48,9 @@ def _combatant(
attack_bonus=2, attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
) )
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant( return Combatant(
id=cid, id=cid,
name=cid, name=cid,
@@ -49,9 +60,9 @@ def _combatant(
str_score=10, str_score=10,
dex_score=10, dex_score=10,
con_score=con, con_score=con,
int_score=10, int_score=int_score,
wis_score=10, wis_score=wis_score,
cha_score=10, cha_score=cha_score,
), ),
hp_max=30, hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10), ac=ACProfile(total=12, touch=12, flat_footed=10),
@@ -61,6 +72,8 @@ def _combatant(
saves=Saves(fort=0, ref=0, will=0), saves=Saves(fort=0, ref=0, will=0),
attacks=[attack], attacks=[attack],
cmb=cmb, 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 assert engine_pass._check_concentration(c, 1, damage_taken=4) is True
engine_fail = _make_engine(caster, grappler, [9])[0] engine_fail = _make_engine(caster, grappler, [9])[0]
assert engine_fail._check_concentration(c, 1, damage_taken=4) is False 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
+1
View File
@@ -380,6 +380,7 @@ def _make_caster(cid: str = "caster") -> Combatant:
saves=Saves(fort=3, ref=4, will=4), saves=Saves(fort=3, ref=4, will=4),
attacks=[attack], attacks=[attack],
casting_ability="int", casting_ability="int",
spell_slots={0: 99, 1: 99, 2: 99, 3: 99},
) )
+4 -3
View File
@@ -164,9 +164,10 @@ def test_melee_vs_helpless_target_gains_plus4() -> None:
a, t = eng._states a, t = eng._states
_add_condition(t, "helpless") _add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) 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.total == 19
assert result.ac == 10 assert result.ac == 5
assert result.hit is True assert result.hit is True
@@ -180,7 +181,7 @@ def test_melee_vs_helpless_plus4_applies_to_crit_confirmation() -> None:
a, t = eng._states a, t = eng._states
_add_condition(t, "helpless") _add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) 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.crit is True
assert result.damage == 8 assert result.damage == 8
+8 -5
View File
@@ -189,13 +189,14 @@ class TestStaggeredActionEconomy:
assert result is False assert result is False
# ── Test 4: nauseated → single standard action only ─────────────────────────── # ── Test 4: nauseated → single move action only ───────────────────────────────
class TestNauseatedActionEconomy: class TestNauseatedActionEconomy:
"""Given: a nauseated combatant """Given: a nauseated combatant
When: _take_turn is called 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: def test_nauseated_no_attack_and_no_full_round(self) -> None:
hero = _make_hero(bab=6) hero = _make_hero(bab=6)
@@ -241,7 +242,7 @@ class TestParalyzedNoActions:
assert found_wait, "paralyzed combatant should log 'wait (cannot act)'" assert found_wait, "paralyzed combatant should log 'wait (cannot act)'"
def test_paralyzed_defender_helpless_ac(self) -> None: 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() hero = _make_hero()
foe = _make_foe() foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4]) engine, h, f = _make_engine(hero, foe, [10, 4])
@@ -249,8 +250,10 @@ class TestParalyzedNoActions:
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test") ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
) )
result = engine.resolve_attack(h, f, hero.attacks[0]) result = engine.resolve_attack(h, f, hero.attacks[0])
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9 # Paralyzed: denies_dex_to_ac → flat_footed AC 13; helpless Dex 0 → -5;
assert result.ac == 9 # 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 ────────────────────────────────── # ── Test 6: StatModifier condition filtering ──────────────────────────────────
+438
View File
@@ -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 == ()
+1
View File
@@ -312,6 +312,7 @@ def test_damage_taken_reaction_fires() -> None:
caster = make_combatant("caster", hp=50) caster = make_combatant("caster", hp=50)
reactor = make_combatant("reactor", hp=50, kind="ranged") reactor = make_combatant("reactor", hp=50, kind="ranged")
caster_state = make_state(caster, "players", (1, 1)) caster_state = make_state(caster, "players", (1, 1))
caster_state.spell_slots_left = {1: 9}
reactor_state = make_state(reactor, "monsters", (1, 3)) reactor_state = make_state(reactor, "monsters", (1, 3))
states = [caster_state, reactor_state] states = [caster_state, reactor_state]
+2
View File
@@ -57,6 +57,7 @@ def make_combatant(
features: list[AbilitySpec] | None = None, features: list[AbilitySpec] | None = None,
spells: list[str] | None = None, spells: list[str] | None = None,
ranged: bool = False, ranged: bool = False,
spell_slots: dict[int, int] | None = None,
) -> Combatant: ) -> Combatant:
"""Build a single-weapon combatant with known static stats. """Build a single-weapon combatant with known static stats.
@@ -103,6 +104,7 @@ def make_combatant(
attacks=[attack], attacks=[attack],
features=features or [], features=features or [],
spells=spells or [], spells=spells or [],
spell_slots=spell_slots or {},
) )
+27 -9
View File
@@ -108,7 +108,9 @@ def _cast_scene(
(distance 0) but not foe_out (distance 5) nor the caster (distance 9). (distance 0) but not foe_out (distance 5) nor the caster (distance 9).
""" """
caster = make_state( 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_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)) 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 When: cast_spell at that unseen point
Then: invalid_destination rejection without casting.""" Then: invalid_destination rejection without casting."""
caster = make_state( 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 6))
eng = _engine([caster, foe], [], grid=_walled_grid()) 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) When: a target-less point cast at (0,4)
Then: no primary save is rolled (queue would shift otherwise) and EACH 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.""" 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_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)) 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) 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 When: a target-less point cast
Then: no saving throw is rolled at all — only the 8 damage dice.""" Then: no saving throw is rolled at all — only the 8 damage dice."""
caster = make_state( 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
eng = _engine([caster, foe], [3] * 8) 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) When: cast_spell with target_id and NO target_pos (the legacy path)
Then: the transcript is the pinned creature-centered line — primary save Then: the transcript is the pinned creature-centered line — primary save
rolled once, reused for the target in the area, 8d6 all 3s.""" 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
eng = _engine([caster, foe], [2] + [3] * 8) eng = _engine([caster, foe], [2] + [3] * 8)
result = eng._execute( 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) target_pos set to the target's own cell (target_id kept)
When: both casts resolve with identical scripted rolls When: both casts resolve with identical scripted rolls
Then: transcripts and hp outcomes are byte-identical.""" 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
plain = _engine([caster, foe], [2] + [3] * 8) plain = _engine([caster, foe], [2] + [3] * 8)
plain._execute( plain._execute(
caster, Action(kind="cast_spell", target_id="foe", spell_name="Fireball") 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)) foe2 = make_state(make_combatant("foe", hp=100), "monsters", (0, 4))
pointed = _engine([caster2, foe2], [2] + [3] * 8) pointed = _engine([caster2, foe2], [2] + [3] * 8)
pointed._execute( 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) Then: target_pos is ignored — the missile hits its creature (1d4+1 = 4)
and the transcript is the ordinary creature-centered line.""" and the transcript is the ordinary creature-centered line."""
caster = make_state( 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
eng = _engine([caster, foe], [3]) 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 When: the action is dispatched with an empty roll queue
Then: nothing crashes, nothing is cast, no die is rolled.""" Then: nothing crashes, nothing is cast, no die is rolled."""
caster = make_state( 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)) foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 2))
eng = _engine([caster, foe], []) eng = _engine([caster, foe], [])
+4 -2
View File
@@ -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 ``conditions`` list (how ``drop_prone`` applies it). ``drop_prone`` is a free
action that goes prone until the combatant stands up. Rejections: standing up action that goes prone until the combatant stands up. Rejections: standing up
while not prone → ``not_prone``; dropping while already prone → while not prone → ``not_prone``; dropping while already prone →
``already_prone``. The engine's existing prone behaviour (flat -4 AC in ``already_prone``. The engine's prone modifiers follow the CRB (B3
``_stat_modifiers``, -4 ranged attacks) is unchanged. 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 from __future__ import annotations
+8 -6
View File
@@ -24,6 +24,7 @@ def _make_combatant(
spell_resistance: int = 0, spell_resistance: int = 0,
resistances: dict[str, int] | None = None, resistances: dict[str, int] | None = None,
immunities: tuple[str, ...] = (), immunities: tuple[str, ...] = (),
spell_slots: dict[int, int] | None = None,
) -> Combatant: ) -> Combatant:
return Combatant( return Combatant(
id=id_, id=id_,
@@ -43,6 +44,7 @@ def _make_combatant(
spell_resistance=spell_resistance, spell_resistance=spell_resistance,
resistances=resistances or {}, resistances=resistances or {},
immunities=immunities, immunities=immunities,
spell_slots=spell_slots or {},
) )
@@ -102,7 +104,7 @@ _FIREBALL = SpellSpec(
def test_sr_blocks_all_effects() -> None: 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) target = _make_combatant("target", spell_resistance=15)
eng = _engine( eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [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: 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) target = _make_combatant("target", spell_resistance=10)
eng = _engine( eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [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: 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) t1 = _make_combatant("t1", spell_resistance=15)
t2 = _make_combatant("t2", spell_resistance=0) t2 = _make_combatant("t2", spell_resistance=0)
eng = _engine( eng = _engine(
@@ -149,7 +151,7 @@ def test_per_target_sr_in_area_spell() -> None:
def test_no_sr_check_when_sr_zero() -> 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) target = _make_combatant("target", spell_resistance=0)
eng = _engine( eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"], [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: 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 = _make_combatant(
"target", "target",
spell_resistance=0, spell_resistance=0,
@@ -182,7 +184,7 @@ def test_fire_resistance_reduces_spell_damage() -> None:
def test_fire_immunity_negates_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 = _make_combatant(
"target", "target",
spell_resistance=0, spell_resistance=0,
+61
View File
@@ -73,6 +73,9 @@ def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
saves=Saves(fort=3, ref=4, will=4), saves=Saves(fort=3, ref=4, will=4),
attacks=[attack], attacks=[attack],
casting_ability="int", 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) 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: class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False) """Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save When: the target succeeds / fails its save