"""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", spell_slots={0: 99, 1: 99, 2: 99, 3: 99}, ) 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