feat(combat): add saving throws (fort/ref/will, natural 1/20, effect modifiers)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user