fix(combat): evaluate conditional stat modifiers with action context
This commit is contained in:
@@ -346,6 +346,14 @@ Règles modélisées :
|
|||||||
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
|
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
|
||||||
appliqués via `resolve_modifiers` (mêmes règles d'empilement que les
|
appliqués via `resolve_modifiers` (mêmes règles d'empilement que les
|
||||||
attaques). Renvoie un `SaveResult` (success, roll, total, dc).
|
attaques). Renvoie un `SaveResult` (success, roll, total, dc).
|
||||||
|
- Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué
|
||||||
|
que si la condition tient au moment de la résolution (`_modifier_condition_holds`) :
|
||||||
|
`target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom
|
||||||
|
contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`,
|
||||||
|
`electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les
|
||||||
|
descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts,
|
||||||
|
conditions de peur, écoles enchantement/divination) et les effets de zone. Toute
|
||||||
|
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`). Le DD vaut `10 + niveau du
|
||||||
@@ -415,10 +423,8 @@ dans la résolution) :
|
|||||||
- Parmi les manœuvres, seules trip / disarm / bull_rush / sunder / grapple / escape
|
- Parmi les manœuvres, seules trip / disarm / bull_rush / sunder / grapple / escape
|
||||||
sont câblées ; les autres (overrun, dirty trick, steal, reposition, drag) ne le
|
sont câblées ; les autres (overrun, dirty trick, steal, reposition, drag) ne le
|
||||||
sont pas. Une arme brisée ne peut pas être réparée en cours de combat.
|
sont pas. Une arme brisée ne peut pas être réparée en cours de combat.
|
||||||
- Les modificateurs d'effets conditionnés par un `condition=` au runtime ne sont pas
|
- La distribution des dégâts par type sur un sort n'envoie que des dégâts simples
|
||||||
évalués (`_modifier_condition_holds` renvoie toujours False, le modificateur est
|
(sans plage « half on save » pour les effets ciblés).
|
||||||
alors exclu) ; la distribution des dégâts par type sur un sort n'envoie que des
|
|
||||||
dégâts simples (sans plage « half on save » pour les effets ciblés).
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -439,7 +445,10 @@ dans la résolution) :
|
|||||||
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
|
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
|
||||||
pour les attaques touch et firearms), états de vie, incantation
|
pour les attaques touch et firearms), états de vie, incantation
|
||||||
(emplacements, concentration par situation, AoO d'incantation), invocation,
|
(emplacements, concentration par situation, AoO d'incantation), invocation,
|
||||||
manœuvres, capacités de classe, transcripts, politique par défaut.
|
manœuvres, capacités de classe, transcripts, politique par défaut. Les
|
||||||
|
modificateurs conditionnés (`StatModifier.condition`) sont évalués via
|
||||||
|
`_modifier_condition_holds` avec le contexte d'action (arme, portée,
|
||||||
|
descripteurs de sauvegarde) ; les conditions inconnues restent exclues.
|
||||||
- `effects.py` — système d'effets : `StatModifier` (modificateur de
|
- `effects.py` — système d'effets : `StatModifier` (modificateur de
|
||||||
caractéristique avec type de bonus) et `resolve_modifiers` (application des
|
caractéristique avec type de bonus) et `resolve_modifiers` (application des
|
||||||
règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait,
|
règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait,
|
||||||
@@ -468,12 +477,15 @@ dans la résolution) :
|
|||||||
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
|
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
|
||||||
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
||||||
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
||||||
`load_spell_registry`) pour `data/spells/`. Le moteur intègre les sorts via
|
`load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde
|
||||||
`Action(kind="cast_spell")` et `_cast_spell` : résolution de sauvegarde,
|
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
|
||||||
application des dégâts (demi-dégâts sur sauvegarde réussie ; la RD ne
|
conditions de peur, écoles enchantement/divination) sont dérivés des effets
|
||||||
s'applique pas aux dégâts de sorts), conditions, soins (plafonnés à hp_max)
|
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
|
||||||
et buffs. Le champ `spells` de `Combatant` contient les noms de sorts connus ;
|
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` :
|
||||||
le `spell_registry` est passé au `CombatEngine`.
|
résolution de sauvegarde, application des dégâts (demi-dégâts sur sauvegarde
|
||||||
|
réussie ; la RD ne s'applique pas aux dégâts de sorts), conditions, soins
|
||||||
|
(plafonnés à hp_max) et buffs. Le champ `spells` de `Combatant` contient les
|
||||||
|
noms de sorts connus ; le `spell_registry` est passé au `CombatEngine`.
|
||||||
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
|
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
|
||||||
`win_rate_band` (bande 3σ bornée à [0, 1]).
|
`win_rate_band` (bande 3σ bornée à [0, 1]).
|
||||||
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
|
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ from pf1e_simulator.spells import (
|
|||||||
HealEffect,
|
HealEffect,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
|
save_descriptors_for_effects,
|
||||||
spell_range_ft,
|
spell_range_ft,
|
||||||
|
spell_save_descriptors,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -123,6 +125,12 @@ _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
|
|||||||
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
||||||
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
||||||
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
|
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
|
||||||
|
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
|
||||||
|
_POINT_BLANK_RANGE_FT = 30
|
||||||
|
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||||
|
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||||
|
"mind_affecting",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
_SIZE_MODIFIERS: dict[str, int] = {
|
_SIZE_MODIFIERS: dict[str, int] = {
|
||||||
@@ -409,15 +417,33 @@ class CombatEngine:
|
|||||||
or any(cond.name == name for cond in state.conditions)
|
or any(cond.name == name for cond in state.conditions)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modifier_condition_holds(self, _state: CombatantState, _condition_name: str) -> bool:
|
def _modifier_condition_holds(
|
||||||
|
self,
|
||||||
|
_state: CombatantState,
|
||||||
|
condition_name: str,
|
||||||
|
*,
|
||||||
|
weapon: AttackSpec | None = None,
|
||||||
|
dist_ft: int | None = None,
|
||||||
|
save_descriptors: frozenset[str] = frozenset(),
|
||||||
|
) -> bool:
|
||||||
"""Evaluate whether a StatModifier's runtime condition holds.
|
"""Evaluate whether a StatModifier's runtime condition holds.
|
||||||
|
|
||||||
Returns False for unknown or unresolvable conditions (modifier excluded).
|
Unknown/unresolvable conditions return False (modifier excluded).
|
||||||
Currently no conditions are evaluable from _stat_modifiers call sites
|
Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft),
|
||||||
alone (they require action-level context like target range or spell
|
"axe" (weapon name contains "axe"), and save-descriptor names (fear,
|
||||||
descriptor), so this always returns False. When the engine gains that
|
cold, ...) that are present in the save's descriptor set.
|
||||||
context, specific conditions can be added here.
|
|
||||||
"""
|
"""
|
||||||
|
if condition_name == "target_within_30ft_ranged":
|
||||||
|
return (
|
||||||
|
weapon is not None
|
||||||
|
and dist_ft is not None
|
||||||
|
and self._is_ranged_attack(weapon)
|
||||||
|
and dist_ft <= _POINT_BLANK_RANGE_FT
|
||||||
|
)
|
||||||
|
if condition_name == "axe":
|
||||||
|
return weapon is not None and "axe" in weapon.name.lower()
|
||||||
|
if condition_name in _SAVE_DESCRIPTOR_CONDITIONS:
|
||||||
|
return condition_name in save_descriptors
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_speed_mult(self, state: CombatantState) -> float:
|
def _get_speed_mult(self, state: CombatantState) -> float:
|
||||||
@@ -559,7 +585,14 @@ class CombatEngine:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _stat_modifiers(
|
def _stat_modifiers(
|
||||||
self, state: CombatantState, target: str, *, weapon_name: str | None = None
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
target: str,
|
||||||
|
*,
|
||||||
|
weapon_name: str | None = None,
|
||||||
|
weapon: AttackSpec | None = None,
|
||||||
|
dist_ft: int | None = None,
|
||||||
|
save_descriptors: frozenset[str] = frozenset(),
|
||||||
) -> list[StatModifier]:
|
) -> list[StatModifier]:
|
||||||
"""Collect all StatModifiers for ``target`` from all sources.
|
"""Collect all StatModifiers for ``target`` from all sources.
|
||||||
|
|
||||||
@@ -569,8 +602,9 @@ class CombatEngine:
|
|||||||
|
|
||||||
Modifiers whose ``condition`` is set are only included when the named
|
Modifiers whose ``condition`` is set are only included when the named
|
||||||
runtime condition holds for ``state`` (evaluated via
|
runtime condition holds for ``state`` (evaluated via
|
||||||
``_modifier_condition_holds``). Unsupported condition names are treated
|
``_modifier_condition_holds``, which consumes the weapon/range/save
|
||||||
as NOT holding (modifier excluded).
|
context threaded from the action being resolved). Unsupported
|
||||||
|
condition names are treated as NOT holding (modifier excluded).
|
||||||
"""
|
"""
|
||||||
mods: list[StatModifier] = []
|
mods: list[StatModifier] = []
|
||||||
# Old effects field (backwards compatibility)
|
# Old effects field (backwards compatibility)
|
||||||
@@ -600,7 +634,14 @@ class CombatEngine:
|
|||||||
return [
|
return [
|
||||||
m
|
m
|
||||||
for m in mods
|
for m in mods
|
||||||
if m.condition is None or self._modifier_condition_holds(state, m.condition)
|
if m.condition is None
|
||||||
|
or self._modifier_condition_holds(
|
||||||
|
state,
|
||||||
|
m.condition,
|
||||||
|
weapon=weapon,
|
||||||
|
dist_ft=dist_ft,
|
||||||
|
save_descriptors=save_descriptors,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
|
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
|
||||||
@@ -642,7 +683,9 @@ class CombatEngine:
|
|||||||
dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT
|
dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT
|
||||||
if dist_ft > self._FEAR_AURA_RANGE_FT:
|
if dist_ft > self._FEAR_AURA_RANGE_FT:
|
||||||
continue
|
continue
|
||||||
save = self.resolve_save(enemy, "will", self._FEAR_AURA_DC)
|
save = self.resolve_save(
|
||||||
|
enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"})
|
||||||
|
)
|
||||||
if save.success:
|
if save.success:
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: fear aura on "
|
f"round {self._current_round} {state.combatant.id}: fear aura on "
|
||||||
@@ -693,7 +736,9 @@ class CombatEngine:
|
|||||||
penalty = self.range_penalty(weapon, dist_ft)
|
penalty = self.range_penalty(weapon, dist_ft)
|
||||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
atk_mods = self._stat_modifiers(
|
||||||
|
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
|
)
|
||||||
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
|
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
|
||||||
atk_mods.append(StatModifier(target="attack", value=-4))
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||||
attack_mod = resolve_modifiers(atk_mods)
|
attack_mod = resolve_modifiers(atk_mods)
|
||||||
@@ -742,7 +787,9 @@ class CombatEngine:
|
|||||||
confirm = self._rng.d20()
|
confirm = self._rng.d20()
|
||||||
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
|
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
|
||||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||||
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
|
dmg_mods = self._stat_modifiers(
|
||||||
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
|
)
|
||||||
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||||
damage += resolve_modifiers(dmg_mods)
|
damage += resolve_modifiers(dmg_mods)
|
||||||
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
||||||
@@ -775,18 +822,26 @@ class CombatEngine:
|
|||||||
return max(0, damage - dr.amount)
|
return max(0, damage - dr.amount)
|
||||||
|
|
||||||
def resolve_save(
|
def resolve_save(
|
||||||
self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
save_type: Literal["fort", "ref", "will"],
|
||||||
|
dc: int,
|
||||||
|
*,
|
||||||
|
save_descriptors: frozenset[str] = frozenset(),
|
||||||
) -> SaveResult:
|
) -> SaveResult:
|
||||||
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
||||||
|
|
||||||
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
|
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
|
||||||
Effect modifiers and condition modifiers (StatModifier with matching
|
Effect modifiers and condition modifiers (StatModifier with matching
|
||||||
target) are applied via ``resolve_modifiers`` — same bonus-type
|
target) are applied via ``resolve_modifiers`` — same bonus-type
|
||||||
stacking rules as attacks.
|
stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"},
|
||||||
|
{"poison"}) gates conditional modifiers like Bitter or Born Damned.
|
||||||
"""
|
"""
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
base = getattr(state.combatant.saves, save_type)
|
base = getattr(state.combatant.saves, save_type)
|
||||||
bonus = resolve_modifiers(self._stat_modifiers(state, save_type))
|
bonus = resolve_modifiers(
|
||||||
|
self._stat_modifiers(state, save_type, save_descriptors=save_descriptors)
|
||||||
|
)
|
||||||
total = roll + base + bonus
|
total = roll + base + bonus
|
||||||
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
||||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||||
@@ -1771,7 +1826,11 @@ class CombatEngine:
|
|||||||
ability_mod = (ability_score - 10) // 2
|
ability_mod = (ability_score - 10) // 2
|
||||||
dc = 10 + spell.level + ability_mod
|
dc = 10 + spell.level + ability_mod
|
||||||
save_result = (
|
save_result = (
|
||||||
self.resolve_save(target, spell.save, dc) if spell.save != "none" else None
|
self.resolve_save(
|
||||||
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
|
)
|
||||||
|
if spell.save != "none"
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
caster_live = self._stats[caster.combatant.id]
|
caster_live = self._stats[caster.combatant.id]
|
||||||
target_live = self._stats[target.combatant.id]
|
target_live = self._stats[target.combatant.id]
|
||||||
@@ -2025,7 +2084,9 @@ class CombatEngine:
|
|||||||
return False
|
return False
|
||||||
if affected_state is target:
|
if affected_state is target:
|
||||||
return saved
|
return saved
|
||||||
save_result = self.resolve_save(affected_state, "ref", dc)
|
save_result = self.resolve_save(
|
||||||
|
affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects)
|
||||||
|
)
|
||||||
return save_result.success
|
return save_result.success
|
||||||
|
|
||||||
def _apply_metamagic(
|
def _apply_metamagic(
|
||||||
|
|||||||
@@ -108,6 +108,34 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
|
|||||||
return 400 + 40 * caster_level
|
return 400 + 40 * caster_level
|
||||||
|
|
||||||
|
|
||||||
|
_FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering"})
|
||||||
|
|
||||||
|
|
||||||
|
def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]:
|
||||||
|
descriptors: set[str] = set()
|
||||||
|
for effect in effects:
|
||||||
|
if isinstance(effect, DamageEffect):
|
||||||
|
descriptors.update(effect.types)
|
||||||
|
elif isinstance(effect, ConditionEffect) and effect.condition_name in _FEAR_CONDITIONS:
|
||||||
|
descriptors.add("fear")
|
||||||
|
elif isinstance(effect, AreaEffect):
|
||||||
|
descriptors.update(save_descriptors_for_effects(effect.effects))
|
||||||
|
return frozenset(descriptors)
|
||||||
|
|
||||||
|
|
||||||
|
def spell_save_descriptors(spell: SpellSpec) -> frozenset[str]:
|
||||||
|
"""Spell save descriptors: effect-derived plus CRB school tags.
|
||||||
|
|
||||||
|
Enchantment is mind_affecting, divination is divination.
|
||||||
|
"""
|
||||||
|
descriptors = set(save_descriptors_for_effects(spell.effects))
|
||||||
|
if spell.school == "enchantment":
|
||||||
|
descriptors.add("mind_affecting")
|
||||||
|
elif spell.school == "divination":
|
||||||
|
descriptors.add("divination")
|
||||||
|
return frozenset(descriptors)
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Json) -> dict[str, Json]:
|
def _as_dict(value: Json) -> dict[str, Json]:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""Tests for conditional StatModifier evaluation with action context (B1).
|
||||||
|
|
||||||
|
Given: a StatModifier whose ``condition`` refers to a runtime property
|
||||||
|
(target range, wielded weapon, save descriptor)
|
||||||
|
When: the engine resolves an attack or a saving throw
|
||||||
|
Then: the modifier applies only when the runtime condition holds;
|
||||||
|
unsupported or unknown condition names stay excluded (security).
|
||||||
|
|
||||||
|
Supported condition families (plan B1):
|
||||||
|
- ``target_within_30ft_ranged`` — ranged-style weapon (or touch with
|
||||||
|
increment) fired at a target within 30 ft (Point-Blank Shot).
|
||||||
|
- ``axe`` — wielded weapon whose name contains "axe" (Axe to Grind).
|
||||||
|
- save descriptors (fear, cold, electricity, fey, divination, poison,
|
||||||
|
disease, mind_affecting) — the saving throw's context, threaded from the
|
||||||
|
fear aura and from spell effects/schools (Bitter, Lonely, Breeze-Kissed,
|
||||||
|
Fey-touched, Spell Vulnerability, Born Damned, Pingre, Half-Forgotten
|
||||||
|
Secrets).
|
||||||
|
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
|
||||||
|
alcohol) and unknown names always evaluate to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import (
|
||||||
|
AXE_TO_GRIND,
|
||||||
|
BITTER,
|
||||||
|
BORN_DAMNED,
|
||||||
|
BREEZE_KISSED,
|
||||||
|
FEY_TOUCHED,
|
||||||
|
FORTIFIED_DRINKER,
|
||||||
|
HALF_FORGOTTEN_SECRETS,
|
||||||
|
LONELY,
|
||||||
|
MOUNTED_COMBAT,
|
||||||
|
PINGRE,
|
||||||
|
POINT_BLANK_SHOT,
|
||||||
|
SPELL_VULNERABILITY_DIVINATION,
|
||||||
|
AbilitySpec,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine, SaveResult
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.effects import StatModifier
|
||||||
|
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 load_spell_registry, spell_save_descriptors
|
||||||
|
|
||||||
|
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||||
|
|
||||||
|
|
||||||
|
def make_combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
hp: int = 20,
|
||||||
|
ac_total: int = 18,
|
||||||
|
attack_bonus: int = 5,
|
||||||
|
damage: str = "1d6",
|
||||||
|
kind: Literal["melee", "ranged", "touch"] = "melee",
|
||||||
|
weapon_name: str = "test weapon",
|
||||||
|
range_increment_ft: int | None = None,
|
||||||
|
features: tuple[AbilitySpec, ...] = (),
|
||||||
|
will: int = 0,
|
||||||
|
fort: int = 0,
|
||||||
|
ref: int = 0,
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name=weapon_name,
|
||||||
|
kind=kind,
|
||||||
|
attack_bonus=attack_bonus,
|
||||||
|
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
|
||||||
|
range_increment_ft=range_increment_ft,
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=12,
|
||||||
|
dex_score=12,
|
||||||
|
con_score=12,
|
||||||
|
int_score=10,
|
||||||
|
wis_score=10,
|
||||||
|
cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=hp,
|
||||||
|
ac=ACProfile(total=ac_total, touch=max(12, ac_total - 6), flat_footed=ac_total - 2),
|
||||||
|
bab=attack_bonus,
|
||||||
|
initiative_mod=2,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=fort, ref=ref, will=will),
|
||||||
|
attacks=[attack],
|
||||||
|
features=list(features),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||||
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
||||||
|
|
||||||
|
|
||||||
|
def make_grid(width: int = 8) -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * width] * 8), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(
|
||||||
|
queue: list[int], states: list[CombatantState], *, width: int = 8
|
||||||
|
) -> CombatEngine:
|
||||||
|
return CombatEngine(ScriptedRng(queue), make_grid(width), states)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_save(
|
||||||
|
feat: AbilitySpec,
|
||||||
|
save_type: Literal["fort", "ref", "will"],
|
||||||
|
dc: int,
|
||||||
|
*,
|
||||||
|
roll: int = 18,
|
||||||
|
save_descriptors: frozenset[str] = frozenset(),
|
||||||
|
) -> SaveResult:
|
||||||
|
hero = make_combatant("hero", features=(feat,), will=0, fort=0, ref=0)
|
||||||
|
h = make_state(hero, "players", (0, 0))
|
||||||
|
engine = make_engine([roll], [h])
|
||||||
|
return engine.resolve_save(h, save_type, dc, save_descriptors=save_descriptors)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Weapon/range conditions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_blank_shot_applies_within_30_ft() -> None:
|
||||||
|
"""PBS: ranged attack at 5 ft gains +1 attack and +1 damage."""
|
||||||
|
archer = make_combatant(
|
||||||
|
"archer",
|
||||||
|
kind="ranged",
|
||||||
|
range_increment_ft=60,
|
||||||
|
features=(POINT_BLANK_SHOT,),
|
||||||
|
)
|
||||||
|
victim = make_combatant("victim", hp=20)
|
||||||
|
a = make_state(archer, "players", (1, 1))
|
||||||
|
t = make_state(victim, "monsters", (1, 2)) # 5 ft
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, archer.attacks[0])
|
||||||
|
# 12 + 5 + 1 (PBS) = 18 >= AC 18; damage 3 + 1 (PBS) = 4
|
||||||
|
assert result.total == 18
|
||||||
|
assert result.hit is True
|
||||||
|
assert t.hp == 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_blank_shot_inactive_beyond_30_ft() -> None:
|
||||||
|
"""PBS: the +1/+1 does not apply at 35 ft even though the weapon is in range."""
|
||||||
|
archer = make_combatant(
|
||||||
|
"archer",
|
||||||
|
kind="ranged",
|
||||||
|
range_increment_ft=60,
|
||||||
|
features=(POINT_BLANK_SHOT,),
|
||||||
|
)
|
||||||
|
victim = make_combatant("victim", ac_total=16, hp=20)
|
||||||
|
a = make_state(archer, "players", (0, 0))
|
||||||
|
t = make_state(victim, "monsters", (0, 7)) # 35 ft
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, archer.attacks[0])
|
||||||
|
# 12 + 5 = 17 >= AC 16; damage 3 (no PBS)
|
||||||
|
assert result.total == 17
|
||||||
|
assert result.hit is True
|
||||||
|
assert t.hp == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_blank_shot_inactive_for_melee_weapons() -> None:
|
||||||
|
"""PBS is ranged-only: a melee attack at 5 ft gets no bonus."""
|
||||||
|
hero = make_combatant("hero", features=(POINT_BLANK_SHOT,))
|
||||||
|
foe = make_combatant("foe", ac_total=16)
|
||||||
|
a = make_state(hero, "players", (1, 1))
|
||||||
|
t = make_state(foe, "monsters", (1, 2)) # 5 ft, melee
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, hero.attacks[0])
|
||||||
|
assert result.total == 17 # no +1
|
||||||
|
assert t.hp == 17 # damage 3, no +1
|
||||||
|
|
||||||
|
|
||||||
|
def test_axe_to_grind_applies_to_axe_weapon() -> None:
|
||||||
|
"""Axe to Grind: +1 damage with a weapon whose name contains 'axe'."""
|
||||||
|
hero = make_combatant("hero", weapon_name="Battleaxe", features=(AXE_TO_GRIND,))
|
||||||
|
foe = make_combatant("foe", ac_total=16, hp=20)
|
||||||
|
a = make_state(hero, "players", (1, 1))
|
||||||
|
t = make_state(foe, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, hero.attacks[0])
|
||||||
|
assert result.hit is True
|
||||||
|
assert t.hp == 16 # 3 + 1 (axe)
|
||||||
|
|
||||||
|
|
||||||
|
def test_axe_to_grind_inactive_without_axe() -> None:
|
||||||
|
"""Axe to Grind: no bonus when the weapon is not an axe."""
|
||||||
|
hero = make_combatant("hero", weapon_name="Shortsword", features=(AXE_TO_GRIND,))
|
||||||
|
foe = make_combatant("foe", ac_total=16, hp=20)
|
||||||
|
a = make_state(hero, "players", (1, 1))
|
||||||
|
t = make_state(foe, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, hero.attacks[0])
|
||||||
|
assert result.hit is True
|
||||||
|
assert t.hp == 17 # 3, no bonus
|
||||||
|
|
||||||
|
|
||||||
|
# ── Save descriptor conditions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("feat", "save_type", "dc", "descriptors"),
|
||||||
|
[
|
||||||
|
(BITTER, "will", 19, frozenset({"fear"})),
|
||||||
|
(LONELY, "will", 19, frozenset({"fear"})),
|
||||||
|
(BREEZE_KISSED, "fort", 20, frozenset({"cold"})),
|
||||||
|
(BREEZE_KISSED, "ref", 20, frozenset({"electricity"})),
|
||||||
|
(FEY_TOUCHED, "will", 19, frozenset({"fey"})),
|
||||||
|
(HALF_FORGOTTEN_SECRETS, "fort", 20, frozenset({"disease"})),
|
||||||
|
(HALF_FORGOTTEN_SECRETS, "will", 20, frozenset({"mind_affecting"})),
|
||||||
|
(BORN_DAMNED, "fort", 20, frozenset({"poison"})),
|
||||||
|
(PINGRE, "fort", 19, frozenset({"poison"})),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_conditioned_save_bonus_applies_when_descriptor_present(
|
||||||
|
feat: AbilitySpec,
|
||||||
|
save_type: Literal["fort", "ref", "will"],
|
||||||
|
dc: int,
|
||||||
|
descriptors: frozenset[str],
|
||||||
|
) -> None:
|
||||||
|
"""A save carrying the matching descriptor activates the conditional bonus."""
|
||||||
|
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
|
||||||
|
assert result.total == dc # 18 + bonus reaches exactly dc
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("feat", "save_type", "dc", "descriptors"),
|
||||||
|
[
|
||||||
|
(BITTER, "will", 19, frozenset({"cold"})),
|
||||||
|
(BREEZE_KISSED, "fort", 19, frozenset({"fear"})),
|
||||||
|
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
|
||||||
|
(BORN_DAMNED, "fort", 19, frozenset({"cold"})),
|
||||||
|
(PINGRE, "fort", 19, frozenset({"cold"})),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_conditioned_save_bonus_excluded_without_matching_descriptor(
|
||||||
|
feat: AbilitySpec,
|
||||||
|
save_type: Literal["fort", "ref", "will"],
|
||||||
|
dc: int,
|
||||||
|
descriptors: frozenset[str],
|
||||||
|
) -> None:
|
||||||
|
"""A save with an unrelated descriptor keeps the conditional bonus excluded."""
|
||||||
|
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
|
||||||
|
assert result.total == 18 # no bonus
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_spell_vulnerability_divination_penalty() -> None:
|
||||||
|
"""Spell Vulnerability: -2 Will/Ref penalty applies on divination saves only."""
|
||||||
|
penalized = _resolve_save(
|
||||||
|
SPELL_VULNERABILITY_DIVINATION, "will", 17,
|
||||||
|
save_descriptors=frozenset({"divination"}), roll=18,
|
||||||
|
)
|
||||||
|
assert penalized.total == 16 # 18 - 2
|
||||||
|
assert penalized.success is False
|
||||||
|
unaffected = _resolve_save(
|
||||||
|
SPELL_VULNERABILITY_DIVINATION, "ref", 17,
|
||||||
|
save_descriptors=frozenset({"fear"}), roll=18,
|
||||||
|
)
|
||||||
|
assert unaffected.total == 18
|
||||||
|
assert unaffected.success is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unsupported / unknown conditions stay excluded ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounted_condition_unsupported_stays_excluded() -> None:
|
||||||
|
"""Mounted Combat: 'mounted' is not evaluable (no mount model) -> excluded."""
|
||||||
|
hero = make_combatant("hero", attack_bonus=5, features=(MOUNTED_COMBAT,))
|
||||||
|
foe = make_combatant("foe", ac_total=16)
|
||||||
|
a = make_state(hero, "players", (1, 1))
|
||||||
|
t = make_state(foe, "monsters", (1, 2))
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, hero.attacks[0])
|
||||||
|
assert result.total == 17 # no +1 mounted bonus
|
||||||
|
assert t.hp == 17 # damage 3, no +1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alcohol_condition_unsupported_stays_excluded() -> None:
|
||||||
|
"""Fortified Drinker: 'alcohol' context never exists -> excluded."""
|
||||||
|
result = _resolve_save(
|
||||||
|
FORTIFIED_DRINKER, "fort", 19, save_descriptors=frozenset({"poison"}), roll=18,
|
||||||
|
)
|
||||||
|
assert result.total == 18 # no +2 alcohol bonus
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_condition_stays_excluded() -> None:
|
||||||
|
"""A condition name with no evaluation rule is excluded (security)."""
|
||||||
|
hero = make_combatant("hero", attack_bonus=5)
|
||||||
|
foe = make_combatant("foe", ac_total=16)
|
||||||
|
a = make_state(hero, "players", (1, 1))
|
||||||
|
t = make_state(foe, "monsters", (1, 2))
|
||||||
|
a.effects.append(StatModifier(target="attack", value=5, condition="bogus_condition"))
|
||||||
|
engine = make_engine([12, 3], [a, t])
|
||||||
|
result = engine.resolve_attack(a, t, hero.attacks[0])
|
||||||
|
assert result.total == 17 # the +5 modifier is excluded
|
||||||
|
assert result.hit is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── End-to-end: fear aura threading ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fear_aura_lich() -> Combatant:
|
||||||
|
return make_combatant(
|
||||||
|
"lich",
|
||||||
|
hp=100,
|
||||||
|
attack_bonus=10,
|
||||||
|
features=(AbilitySpec(name="Fear Aura", category="class"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fear_aura_save_carries_fear_descriptor() -> None:
|
||||||
|
"""The lich's fear aura save carries 'fear': Lonely's +1 flips the save."""
|
||||||
|
lich = _make_fear_aura_lich()
|
||||||
|
foe = make_combatant("foe", will=0, features=(LONELY,))
|
||||||
|
lich_state = make_state(lich, "monsters", (1, 1))
|
||||||
|
foe_state = make_state(foe, "players", (1, 2)) # 5 ft, inside aura range
|
||||||
|
engine = make_engine([21], [lich_state, foe_state]) # 21 + 0 + 1 = 22 >= DC 22
|
||||||
|
engine._apply_fear_aura(lich_state)
|
||||||
|
assert not any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fear_aura_without_fear_bonus_fails_save() -> None:
|
||||||
|
"""Without the +1, the same d20 fails the aura save and applies shaken."""
|
||||||
|
lich = _make_fear_aura_lich()
|
||||||
|
foe = make_combatant("foe", will=0)
|
||||||
|
lich_state = make_state(lich, "monsters", (1, 1))
|
||||||
|
foe_state = make_state(foe, "players", (1, 2))
|
||||||
|
engine = make_engine([21], [lich_state, foe_state]) # 21 < DC 22
|
||||||
|
engine._apply_fear_aura(lich_state)
|
||||||
|
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
|
|
||||||
|
# ── End-to-end: spell save descriptor threading ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_caster(cid: str = "caster") -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="dagger",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=2,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=5,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=14, con_score=12,
|
||||||
|
int_score=18, wis_score=12, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=20,
|
||||||
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||||
|
bab=2,
|
||||||
|
initiative_mod=2,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=3, ref=4, will=4),
|
||||||
|
attacks=[attack],
|
||||||
|
casting_ability="int",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_target(
|
||||||
|
cid: str = "target", *, features: tuple[AbilitySpec, ...] = ()
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="club",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=3,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=30,
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=2, will=2),
|
||||||
|
attacks=[attack],
|
||||||
|
features=list(features),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_spell_engine(
|
||||||
|
caster: Combatant, target: Combatant, queue: list[int]
|
||||||
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||||
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||||
|
t = CombatantState(combatant=target, side="monsters", pos=(3, 0), hp=target.hp_max)
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
reg = load_spell_registry(_SPELLS_DIR)
|
||||||
|
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
|
||||||
|
return engine, c, t
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_person_save_carries_mind_affecting_descriptor() -> None:
|
||||||
|
"""Hold Person (enchantment) save carries 'mind_affecting': trait +2 flips it.
|
||||||
|
|
||||||
|
Caster level 5, INT 18 -> DC = 10 + 3 (level) + 4 (mod) = 17. A d20 of 13
|
||||||
|
vs will 2 is 15 (< 17, fails) without the trait and 17 with it (saves).
|
||||||
|
"""
|
||||||
|
caster = _make_caster()
|
||||||
|
target = _make_target(features=(HALF_FORGOTTEN_SECRETS,))
|
||||||
|
engine, c, t = _make_spell_engine(caster, target, [13])
|
||||||
|
engine._cast_spell(c, t, "Hold Person")
|
||||||
|
assert not any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_person_without_bonus_fails_save() -> None:
|
||||||
|
"""Without the trait the same d20 fails the Hold Person save."""
|
||||||
|
caster = _make_caster()
|
||||||
|
target = _make_target()
|
||||||
|
engine, c, t = _make_spell_engine(caster, target, [13])
|
||||||
|
engine._cast_spell(c, t, "Hold Person")
|
||||||
|
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Spell save descriptor derivation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("spell_name", "expected"),
|
||||||
|
[
|
||||||
|
("Lightning Bolt", frozenset({"electricity"})),
|
||||||
|
("Fear", frozenset({"fear"})),
|
||||||
|
("Fireball", frozenset({"fire"})),
|
||||||
|
("Hold Person", frozenset({"mind_affecting"})),
|
||||||
|
("Bull's Strength", frozenset()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_spell_save_descriptors_derivation(spell_name: str, expected: frozenset[str]) -> None:
|
||||||
|
"""Damage types, fear conditions and school tags map to save descriptors."""
|
||||||
|
reg = load_spell_registry(_SPELLS_DIR)
|
||||||
|
assert spell_save_descriptors(reg[spell_name]) == expected
|
||||||
Reference in New Issue
Block a user