feat(combat): add saving throws (fort/ref/will, natural 1/20, effect modifiers)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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