diff --git a/README.md b/README.md index 47ace63..13d5a45 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,14 @@ Règles modélisées : modificateurs d'effets (`StatModifier` avec `target` correspondant) sont appliqués via `resolve_modifiers` (mêmes règles d'empilement que les 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 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 @@ -415,10 +423,8 @@ dans la résolution) : - 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 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 - évalués (`_modifier_condition_holds` renvoie toujours False, le modificateur est - 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). +- 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 @@ -439,7 +445,10 @@ dans la résolution) : 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 (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 caractéristique avec type de bonus) et `resolve_modifiers` (application des 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`, `HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium, long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, - `load_spell_registry`) pour `data/spells/`. Le moteur intègre les sorts via - `Action(kind="cast_spell")` et `_cast_spell` : 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`. + `load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde + (`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts, + conditions de peur, écoles enchantement/divination) sont dérivés des effets + pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur + intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` : + 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`, `win_rate_band` (bande 3σ bornée à [0, 1]). - `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone + diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b2f96cf..4667c4d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -93,7 +93,9 @@ from pf1e_simulator.spells import ( HealEffect, SpellEffect, SummonEffect, + save_descriptors_for_effects, spell_range_ft, + spell_save_descriptors, ) 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_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 +# 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] = { @@ -409,15 +417,33 @@ class CombatEngine: 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. - Returns False for unknown or unresolvable conditions (modifier excluded). - Currently no conditions are evaluable from _stat_modifiers call sites - alone (they require action-level context like target range or spell - descriptor), so this always returns False. When the engine gains that - context, specific conditions can be added here. + Unknown/unresolvable conditions return False (modifier excluded). + Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft), + "axe" (weapon name contains "axe"), and save-descriptor names (fear, + cold, ...) that are present in the save's descriptor set. """ + 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 def _get_speed_mult(self, state: CombatantState) -> float: @@ -559,7 +585,14 @@ class CombatEngine: return 0 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]: """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 runtime condition holds for ``state`` (evaluated via - ``_modifier_condition_holds``). Unsupported condition names are treated - as NOT holding (modifier excluded). + ``_modifier_condition_holds``, which consumes the weapon/range/save + context threaded from the action being resolved). Unsupported + condition names are treated as NOT holding (modifier excluded). """ mods: list[StatModifier] = [] # Old effects field (backwards compatibility) @@ -600,7 +634,14 @@ class CombatEngine: return [ m 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: @@ -642,7 +683,9 @@ class CombatEngine: dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT if dist_ft > self._FEAR_AURA_RANGE_FT: 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: self._log( f"round {self._current_round} {state.combatant.id}: fear aura on " @@ -693,7 +736,9 @@ class CombatEngine: penalty = self.range_penalty(weapon, dist_ft) 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 - 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"): atk_mods.append(StatModifier(target="attack", value=-4)) attack_mod = resolve_modifiers(atk_mods) @@ -742,7 +787,9 @@ class CombatEngine: confirm = self._rng.d20() confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt 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 += resolve_modifiers(dmg_mods) damage += pa_amt * 2 if weapon.kind == "melee" else 0 @@ -775,18 +822,26 @@ class CombatEngine: return max(0, damage - dr.amount) 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: """Roll a saving throw (fort/ref/will) against ``dc``. PF1e: natural 1 = automatic failure, natural 20 = automatic success. Effect modifiers and condition modifiers (StatModifier with matching 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() 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 success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) return SaveResult(success=success, roll=roll, total=total, dc=dc) @@ -1771,7 +1826,11 @@ class CombatEngine: ability_mod = (ability_score - 10) // 2 dc = 10 + spell.level + ability_mod 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] target_live = self._stats[target.combatant.id] @@ -2025,7 +2084,9 @@ class CombatEngine: return False if affected_state is target: 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 def _apply_metamagic( diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 7b5e4c4..a5599fb 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -108,6 +108,34 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: 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]: if isinstance(value, dict): return value diff --git a/tests/test_conditional_modifiers.py b/tests/test_conditional_modifiers.py new file mode 100644 index 0000000..3f795be --- /dev/null +++ b/tests/test_conditional_modifiers.py @@ -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