From 2cd8a33a4db9afed7ed2d1e2b0bfdfd39230dd3c Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): add saving throws (fort/ref/will, natural 1/20, effect modifiers) --- README.md | 24 +++-- src/pf1e_simulator/combat.py | 26 +++++ tests/test_saves.py | 180 +++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 tests/test_saves.py diff --git a/README.md b/README.md index e497263..47054a0 100644 --- a/README.md +++ b/README.md @@ -330,13 +330,18 @@ Règles modélisées : du premier, appliquée au jet d'attaque et à la confirmation de critique ; l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet (5 incréments max) ne sont pas distinguées des armes à projectiles. +- Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will. + 1 naturel = échec automatique, 20 naturel = succès automatique. Les + 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). Non modélisé (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : -- Sorts, jets de sauvegarde, conditions et états (le système d'effets et les - règles d'empilement sont en place via `effects.py` — l'intégration au - moteur est en cours). +- Sorts et conditions (le système d'effets `effects.py`, les règles + d'empilement des bonus et les jets de sauvegarde `resolve_save` sont en + place — l'intégration des sorts et conditions au moteur est en cours). - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft @@ -380,7 +385,7 @@ dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 241 tests +uv run pytest -q # 253 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -393,11 +398,12 @@ uv run basedpyright src # mode strict ## Feuille de route -- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - effets paramétrés, conditions, manœuvres de combat, dons et capacités de - classe. Flanquement, attaques à outrance, attaques d'opportunité, charge, - retraite et pas de placement sont déjà modélisés. Le système d'effets - (`effects.py`) et les règles d'empilement des bonus sont en place. +- **Phase 1** — magie et états : sorts modélisés comme effets paramétrés, + conditions, manœuvres de combat, dons et capacités de classe. Flanquement, + attaques à outrance, attaques d'opportunité, charge, retraite et pas de + placement sont déjà modélisés. Le système d'effets (`effects.py`), les + règles d'empilement des bonus et les jets de sauvegarde (`resolve_save`) + sont en place. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. - **Phase 3** — rapporteur LLM local : agrégation des statistiques et diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2c41c80..743eb10 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -189,6 +189,16 @@ class AttackResult: base_bonus: int = 0 +@dataclass(frozen=True) +class SaveResult: + """Outcome of one saving throw roll.""" + + success: bool + roll: int + total: int + dc: int + + @dataclass(frozen=True) class Action: """What a policy wants a combatant to do this turn. @@ -401,6 +411,22 @@ class CombatEngine: return damage return max(0, damage - dr.amount) + def resolve_save( + self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int + ) -> SaveResult: + """Roll a saving throw (fort/ref/will) against ``dc``. + + PF1e: natural 1 = automatic failure, natural 20 = automatic success. + Effect modifiers (StatModifier with matching target) are applied via + ``resolve_modifiers`` — same bonus-type stacking rules as attacks. + """ + roll = self._rng.d20() + base = getattr(state.combatant.saves, save_type) + bonus = resolve_modifiers(e for e in state.effects if e.target == save_type) + 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) + def _roll_initiative(self) -> list[CombatantState]: rolls: dict[str, tuple[int, int]] = {} for state in self._states: diff --git a/tests/test_saves.py b/tests/test_saves.py new file mode 100644 index 0000000..4df6412 --- /dev/null +++ b/tests/test_saves.py @@ -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