fix(combat): evaluate conditional stat modifiers with action context

This commit is contained in:
2026-09-05 12:17:58 +02:00
parent 31d970dd5f
commit 2f2acd992f
4 changed files with 597 additions and 29 deletions
+23 -11
View File
@@ -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 +
+79 -18
View File
@@ -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(
+28
View File
@@ -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
+467
View File
@@ -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