fix(rules): correct P1 PF1e conformance bugs (area saves, concentration, prone, slots, helpless, nauseated)
This commit is contained in:
@@ -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
@@ -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 ()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────
|
||||||
|
|||||||
@@ -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 == ()
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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 {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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], [])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user