feat(combat): add saving throws (fort/ref/will, natural 1/20, effect modifiers)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 88f11de40f
commit 2cd8a33a4d
3 changed files with 221 additions and 9 deletions
+15 -9
View File
@@ -330,13 +330,18 @@ Règles modélisées :
du premier, appliquée au jet d'attaque et à la confirmation de critique ; 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 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. (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 Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) : dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états (le système d'effets et les - Sorts et conditions (le système d'effets `effects.py`, les règles
règles d'empilement sont en place via `effects.py` — l'intégration au d'empilement des bonus et les jets de sauvegarde `resolve_save` sont en
moteur est en cours). place — l'intégration des sorts et conditions au moteur est en cours).
- Manœuvres de combat. - Manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft - Tailles Large+ (2×2), allonge > 5 ft
@@ -380,7 +385,7 @@ dans la résolution) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 241 tests uv run pytest -q # 253 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
@@ -393,11 +398,12 @@ uv run basedpyright src # mode strict
## Feuille de route ## Feuille de route
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - **Phase 1** — magie et états : sorts modélisés comme effets paramétrés,
effets paramétrés, conditions, manœuvres de combat, dons et capacités de conditions, manœuvres de combat, dons et capacités de classe. Flanquement,
classe. Flanquement, attaques à outrance, attaques d'opportunité, charge, attaques à outrance, attaques d'opportunité, charge, retraite et pas de
retraite et pas de placement sont déjà modélisés. Le système d'effets placement sont déjà modélisés. Le système d'effets (`effects.py`), les
(`effects.py`) et les règles d'empilement des bonus sont en place. 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 - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et - **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+26
View File
@@ -189,6 +189,16 @@ class AttackResult:
base_bonus: int = 0 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) @dataclass(frozen=True)
class Action: class Action:
"""What a policy wants a combatant to do this turn. """What a policy wants a combatant to do this turn.
@@ -401,6 +411,22 @@ class CombatEngine:
return damage return damage
return max(0, damage - dr.amount) 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]: def _roll_initiative(self) -> list[CombatantState]:
rolls: dict[str, tuple[int, int]] = {} rolls: dict[str, tuple[int, int]] = {}
for state in self._states: for state in self._states:
+180
View File
@@ -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