"""Tests for saving throws: fort/ref/will, natural 1/20, effect modifiers.""" from __future__ import annotations from pf1e_simulator.combat import CombatantState, CombatEngine 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 def _make_saves_combatant( cid: str, *, fort: int = 0, ref: int = 0, will: int = 0 ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="unarmed", kind="melee", attack_bonus=0, damage=[DamageComponent(formula=parse_dice("1d4"), 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=10, ac=ACProfile(total=15, touch=15, flat_footed=15), bab=0, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=fort, ref=ref, will=will), attacks=[attack], ) def _make_engine_with_state( combatant: Combatant, queue: list[int] ) -> tuple[CombatEngine, CombatantState]: state = CombatantState(combatant=combatant, side="players", pos=(0, 0), hp=10) legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) grid = Grid.from_spec(spec) engine = CombatEngine(ScriptedRng(queue), grid, [state]) return engine, state # ── Basic success/failure ───────────────────────────────────────────────────── class TestSaveBasic: """Given: a combatant with known saves and a scripted RNG When: resolve_save is called Then: returns correct success/failure based on roll + base vs DC.""" def test_fort_save_success(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [10]) result = engine.resolve_save(state, "fort", dc=12) assert result.success is True assert result.roll == 10 assert result.total == 15 def test_fort_save_failure(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [3]) result = engine.resolve_save(state, "fort", dc=12) assert result.success is False assert result.roll == 3 assert result.total == 8 def test_ref_save_success(self) -> None: hero = _make_saves_combatant("hero", ref=4) engine, state = _make_engine_with_state(hero, [14]) result = engine.resolve_save(state, "ref", dc=15) assert result.success is True assert result.total == 18 def test_will_save_failure(self) -> None: hero = _make_saves_combatant("hero", will=-1) engine, state = _make_engine_with_state(hero, [10]) result = engine.resolve_save(state, "will", dc=12) assert result.success is False assert result.total == 9 def test_exact_dc_succeeds(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [7]) result = engine.resolve_save(state, "fort", dc=12) assert result.success is True assert result.total == 12 # ── Natural 1 and natural 20 ───────────────────────────────────────────────── class TestNaturalRolls: """Given: natural 1 or natural 20 on the d20 When: resolve_save is called Then: natural 1 always fails, natural 20 always succeeds.""" def test_natural_1_auto_fails_even_if_total_meets_dc(self) -> None: hero = _make_saves_combatant("hero", fort=20) engine, state = _make_engine_with_state(hero, [1]) result = engine.resolve_save(state, "fort", dc=10) assert result.success is False assert result.roll == 1 assert result.total == 21 def test_natural_20_auto_succeeds_even_if_total_misses_dc(self) -> None: hero = _make_saves_combatant("hero", fort=0) engine, state = _make_engine_with_state(hero, [20]) result = engine.resolve_save(state, "fort", dc=30) assert result.success is True assert result.roll == 20 assert result.total == 20 # ── Effect modifiers ───────────────────────────────────────────────────────── class TestSaveEffects: """Given: a combatant with effect modifiers on saves When: resolve_save is called Then: modifiers are applied via resolve_modifiers (stacking rules).""" def test_effect_bonus_adds_to_save(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [10]) state.effects.append(StatModifier(target="fort", value=2)) result = engine.resolve_save(state, "fort", dc=15) assert result.success is True assert result.total == 17 def test_effect_penalty_reduces_save(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [10]) state.effects.append(StatModifier(target="fort", value=-2)) result = engine.resolve_save(state, "fort", dc=15) assert result.success is False assert result.total == 13 def test_two_morale_bonuses_keep_highest(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [10]) state.effects.append(StatModifier(target="fort", value=2, bonus_type="morale")) state.effects.append(StatModifier(target="fort", value=3, bonus_type="morale")) result = engine.resolve_save(state, "fort", dc=15) assert result.success is True assert result.total == 18 # 10 + 5 + max(2, 3) = 18 def test_untyped_bonuses_stack(self) -> None: hero = _make_saves_combatant("hero", fort=5) engine, state = _make_engine_with_state(hero, [10]) state.effects.append(StatModifier(target="fort", value=1)) state.effects.append(StatModifier(target="fort", value=2)) result = engine.resolve_save(state, "fort", dc=18) assert result.success is True assert result.total == 18 # 10 + 5 + 1 + 2 = 18 def test_effect_on_fort_does_not_affect_ref(self) -> None: hero = _make_saves_combatant("hero", fort=5, ref=3) engine, state = _make_engine_with_state(hero, [10]) state.effects.append(StatModifier(target="fort", value=10)) result = engine.resolve_save(state, "ref", dc=15) assert result.success is False assert result.total == 13 # 10 + 3, fort bonus ignored