feat(conditions): add condition system with 18 PF1e conditions

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 2cd8a33a4d
commit a32444f804
4 changed files with 558 additions and 12 deletions
+12 -4
View File
@@ -339,9 +339,10 @@ Règles modélisées :
Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) :
- 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).
- Sorts (le système d'effets `effects.py`, les règles d'empilement des
bonus, les jets de sauvegarde `resolve_save` et le système de conditions
`conditions.py` sont en place — l'intégration des sorts au moteur est en
cours).
- Manœuvres de combat.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft
@@ -370,6 +371,13 @@ dans la résolution) :
sans type) s'additionnent ; types non-cumulables (morale, sacré, profane,
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec
modificateurs + flags comportementaux) et 18 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py``EncounterSpec`/`Side`, `build_states` (placement en zone +
@@ -385,7 +393,7 @@ dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 253 tests
uv run pytest -q # 276 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
+20 -8
View File
@@ -84,6 +84,7 @@ from pf1e_simulator.effects import StatModifier, resolve_modifiers
if TYPE_CHECKING:
from typing import Literal
from pf1e_simulator.conditions import Condition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
@@ -159,6 +160,7 @@ class CombatantState:
pos: Pos
hp: int
effects: list[StatModifier] = field(default_factory=list)
conditions: list[Condition] = field(default_factory=list)
moved_this_turn: bool = False
@property
@@ -355,6 +357,13 @@ class CombatEngine:
return _FLANK_BONUS
return 0
def _stat_modifiers(self, state: CombatantState, target: str) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from effects + conditions."""
mods = [e for e in state.effects if e.target == target]
for cond in state.conditions:
mods.extend(m for m in cond.modifiers if m.target == target)
return mods
def resolve_attack(
self,
attacker: CombatantState,
@@ -369,10 +378,10 @@ class CombatEngine:
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
total = roll + base_bonus + penalty + flank
ac = target.combatant.ac.total + resolve_modifiers(
e for e in target.effects if e.target == "ac"
)
atk_mods = self._stat_modifiers(attacker, "attack")
attack_mod = resolve_modifiers(atk_mods)
total = roll + base_bonus + penalty + flank + attack_mod
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
)
@@ -390,9 +399,11 @@ class CombatEngine:
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
confirm_total = confirm + base_bonus + penalty + flank
confirm_total = confirm + base_bonus + penalty + flank + attack_mod
crit = confirm != _NATURAL_ONE and confirm_total >= ac
dmg_mods = self._stat_modifiers(attacker, "damage")
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
if crit:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
@@ -417,12 +428,13 @@ class CombatEngine:
"""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.
Effect modifiers and condition 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)
bonus = resolve_modifiers(self._stat_modifiers(state, 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)
+210
View File
@@ -0,0 +1,210 @@
"""PF1e conditions: predefined stat modifiers and behavioral flags.
Each condition maps to a set of StatModifier effects (applied via
resolve_modifiers with PF1e stacking rules) and optional behavioral flags
that the engine checks at relevant resolution points.
Behavioral flags are declared here but only stat modifiers are integrated
into resolve_attack/resolve_save in this step. Flag enforcement (cant_act,
must_flee, etc.) will be added incrementally with spell/condition application.
Sources: PRPG Core Rulebook, Conditions chapter.
"""
from __future__ import annotations
from dataclasses import dataclass
from pf1e_simulator.effects import StatModifier
@dataclass(frozen=True)
class Condition:
"""A PF1e condition: stat modifiers + behavioral flags.
``modifiers`` are collected alongside transient ``effects`` by
``resolve_modifiers``. Behavioral flags are checked by the engine at
relevant points (e.g. ``cant_act`` in ``_take_turn``).
"""
name: str
modifiers: tuple[StatModifier, ...] = ()
denies_dex_to_ac: bool = False
cant_act: bool = False
cant_move: bool = False
cant_attack: bool = False
cant_charge: bool = False
cant_aoo: bool = False
must_flee: bool = False
speed_mult: float = 1.0
# ── Fear conditions ──────────────────────────────────────────────────────────
# Shaken: -2 attack rolls, saving throws, skill checks, ability checks (CRB)
SHAKEN = Condition(
name="shaken",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="fort", value=-2),
StatModifier(target="ref", value=-2),
StatModifier(target="will", value=-2),
),
)
# Frightened: shaken penalties + must flee from source of fear (CRB)
FRIGHTENED = Condition(
name="frightened",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="fort", value=-2),
StatModifier(target="ref", value=-2),
StatModifier(target="will", value=-2),
),
must_flee=True,
)
# Panicked: drop items + flee at top speed + -2 saves (CRB)
PANICKED = Condition(
name="panicked",
modifiers=(
StatModifier(target="fort", value=-2),
StatModifier(target="ref", value=-2),
StatModifier(target="will", value=-2),
),
must_flee=True,
)
# Cowering: frozen in fear, can't act, -2 AC, loses Dex (CRB)
COWERING = Condition(
name="cowering",
modifiers=(
StatModifier(target="ac", value=-2),
),
denies_dex_to_ac=True,
cant_act=True,
)
# ── Physical conditions ─────────────────────────────────────────────────────
# Sickened: -2 attack, weapon damage, saving throws, skill/ability checks (CRB)
SICKENED = Condition(
name="sickened",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="damage", value=-2),
StatModifier(target="fort", value=-2),
StatModifier(target="ref", value=-2),
StatModifier(target="will", value=-2),
),
)
# Fatigued: -2 STR, -2 DEX; can't run or charge (CRB)
# Approximation: -1 attack (STR/DEX), -1 damage (STR)
FATIGUED = Condition(
name="fatigued",
modifiers=(
StatModifier(target="attack", value=-1),
StatModifier(target="damage", value=-1),
),
cant_charge=True,
)
# Exhausted: -6 STR, -6 DEX; half speed; can't run or charge (CRB)
# Approximation: -3 attack, -3 damage
EXHAUSTED = Condition(
name="exhausted",
modifiers=(
StatModifier(target="attack", value=-3),
StatModifier(target="damage", value=-3),
),
cant_charge=True,
speed_mult=0.5,
)
# Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB)
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX)
ENTANGLED = Condition(
name="entangled",
modifiers=(
StatModifier(target="attack", value=-2),
StatModifier(target="ac", value=-2),
StatModifier(target="ref", value=-2),
),
cant_charge=True,
speed_mult=0.5,
)
# ── Sensory conditions ──────────────────────────────────────────────────────
# Dazzled: -1 attack rolls (CRB)
DAZZLED = Condition(
name="dazzled",
modifiers=(
StatModifier(target="attack", value=-1),
),
)
# Blinded: -2 AC, loses Dex, -4 STR/DEX skill checks (CRB)
BLINDED = Condition(
name="blinded",
modifiers=(
StatModifier(target="ac", value=-2),
StatModifier(target="attack", value=-2),
),
denies_dex_to_ac=True,
)
# Deafened: -4 initiative, 20% spell failure (verbal) (CRB)
DEAFENED = Condition(
name="deafened",
modifiers=(
StatModifier(target="initiative", value=-4),
),
)
# ── Positional conditions ────────────────────────────────────────────────────
# Flat-footed: loses Dex to AC, can't make AoO (CRB)
FLAT_FOOTED = Condition(
name="flat_footed",
denies_dex_to_ac=True,
cant_aoo=True,
)
# Prone: -4 melee attack, +4 AC vs ranged, -4 AC vs melee (CRB)
PRONE = Condition(
name="prone",
)
# ── Incapacitating conditions ────────────────────────────────────────────────
# Stunned: can't act, -2 AC, loses Dex (CRB)
STUNNED = Condition(
name="stunned",
modifiers=(
StatModifier(target="ac", value=-2),
),
denies_dex_to_ac=True,
cant_act=True,
)
# Paralyzed: can't move or act, effective STR/DEX = 0, helpless (CRB)
PARALYZED = Condition(
name="paralyzed",
denies_dex_to_ac=True,
cant_act=True,
cant_move=True,
)
# Nauseated: can't attack/cast/concentrate; only single move action (CRB)
NAUSEATED = Condition(
name="nauseated",
cant_attack=True,
)
# Dazed: can't act, no AC penalty (CRB)
DAZED = Condition(
name="dazed",
cant_act=True,
)
# Staggered: only single move or standard action, no full-round (CRB)
STAGGERED = Condition(
name="staggered",
)
+316
View File
@@ -0,0 +1,316 @@
"""Tests for PF1e conditions: stat modifiers applied to attack/AC/saves."""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import (
BLINDED,
DAZZLED,
ENTANGLED,
FATIGUED,
FLAT_FOOTED,
FRIGHTENED,
SHAKEN,
SICKENED,
STUNNED,
)
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_hero(cid: str = "hero") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
damage_bonus=2,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=14, dex_score=12, con_score=12,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=18, touch=12, flat_footed=16),
bab=5,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=5, ref=4, will=3),
attacks=[attack],
)
def _make_foe(cid: str = "foe") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=12, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=15,
ac=ACProfile(total=15, touch=10, flat_footed=13),
bab=3,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=3, ref=2, will=1),
attacks=[attack],
)
def _make_engine(
hero: Combatant, foe: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max)
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, [h, f])
return engine, h, f
# ── Condition data: modifiers and flags ──────────────────────────────────────
class TestConditionData:
"""Given: the predefined condition constants
When: inspecting their modifiers and flags
Then: they match the CRB rules."""
def test_shaken_modifiers(self) -> None:
targets = {m.target for m in SHAKEN.modifiers}
assert targets == {"attack", "fort", "ref", "will"}
assert all(m.value == -2 for m in SHAKEN.modifiers)
def test_sickened_modifiers(self) -> None:
targets = {m.target for m in SICKENED.modifiers}
assert targets == {"attack", "damage", "fort", "ref", "will"}
assert all(m.value == -2 for m in SICKENED.modifiers)
def test_dazzled_only_attack(self) -> None:
assert len(DAZZLED.modifiers) == 1
assert DAZZLED.modifiers[0].target == "attack"
assert DAZZLED.modifiers[0].value == -1
def test_entangled_flags(self) -> None:
assert ENTANGLED.cant_charge is True
assert ENTANGLED.speed_mult == 0.5
def test_fatigued_flags(self) -> None:
assert FATIGUED.cant_charge is True
def test_flat_footed_flags(self) -> None:
assert FLAT_FOOTED.denies_dex_to_ac is True
assert FLAT_FOOTED.cant_aoo is True
def test_stunned_flags(self) -> None:
assert STUNNED.cant_act is True
assert STUNNED.denies_dex_to_ac is True
def test_frightened_flags(self) -> None:
assert FRIGHTENED.must_flee is True
def test_blinded_flags(self) -> None:
assert BLINDED.denies_dex_to_ac is True
# ── Condition applied to attack rolls ────────────────────────────────────────
class TestConditionOnAttack:
"""Given: an attacker with a condition that penalizes attack
When: resolve_attack is called
Then: the penalty is applied to the attack total."""
def test_shaken_attacker_has_minus2_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SHAKEN)
result = engine.resolve_attack(h, f, hero.attacks[0])
# base_bonus=5, roll=10, flank=0, penalty=0, attack_mod=-2
assert result.total == 10 + 5 - 2 # 13
def test_dazzled_attacker_has_minus1_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(DAZZLED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.total == 10 + 5 - 1 # 14
def test_sickened_attacker_has_minus2_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SICKENED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.total == 10 + 5 - 2 # 13
def test_no_condition_normal_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.total == 10 + 5 # 15
# ── Condition applied to damage rolls ────────────────────────────────────────
class TestConditionOnDamage:
"""Given: an attacker with a condition that penalizes damage
When: resolve_attack hits
Then: the penalty is applied to damage."""
def test_sickened_attacker_damage_reduced_by_2(self) -> None:
hero = _make_hero()
foe = _make_foe()
# roll=15 (hit), damage die=4
engine, h, f = _make_engine(hero, foe, [15, 4])
h.conditions.append(SICKENED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.hit is True
# damage = 4 (die) + 2 (bonus) - 2 (sickened) = 4
assert result.damage == 4
def test_no_condition_normal_damage(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [15, 4])
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.hit is True
# damage = 4 (die) + 2 (bonus) = 6
assert result.damage == 6
# ── Condition applied to AC ──────────────────────────────────────────────────
class TestConditionOnAC:
"""Given: a defender with a condition that modifies AC
When: resolve_attack is called
Then: the AC is adjusted accordingly."""
def test_stunned_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(STUNNED)
result = engine.resolve_attack(h, f, hero.attacks[0])
# foe AC 15 - 2 (stunned) = 13
assert result.ac == 13
def test_blinded_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(BLINDED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
def test_entangled_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(ENTANGLED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
# ── Condition applied to saving throws ───────────────────────────────────────
class TestConditionOnSaves:
"""Given: a combatant with a condition that penalizes saves
When: resolve_save is called
Then: the penalty is applied to the save total."""
def test_shaken_minus2_to_all_saves(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10, 10, 10])
h.conditions.append(SHAKEN)
fort = engine.resolve_save(h, "fort", dc=12)
assert fort.total == 10 + 5 - 2 # 13
ref = engine.resolve_save(h, "ref", dc=12)
assert ref.total == 10 + 4 - 2 # 12
will = engine.resolve_save(h, "will", dc=12)
assert will.total == 10 + 3 - 2 # 11
def test_sickened_minus2_to_all_saves(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10])
h.conditions.append(SICKENED)
result = engine.resolve_save(h, "will", dc=12)
assert result.total == 10 + 3 - 2 # 11
def test_entangled_minus2_ref_only(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10, 10])
h.conditions.append(ENTANGLED)
ref = engine.resolve_save(h, "ref", dc=12)
assert ref.total == 10 + 4 - 2 # 12
fort = engine.resolve_save(h, "fort", dc=12)
assert fort.total == 10 + 5 # 15, no penalty to fort
# ── Effects + conditions together ────────────────────────────────────────────
class TestEffectsWithConditions:
"""Given: a combatant with both a transient effect and a condition
When: resolve_attack or resolve_save is called
Then: both modifiers are collected and stacking rules apply."""
def test_effect_and_condition_stack_untyped(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
h.conditions.append(SHAKEN) # -2 untyped to attack
h.effects.append(StatModifier(target="attack", value=1)) # +1 untyped
result = engine.resolve_attack(h, f, hero.attacks[0])
# 10 + 5 + (-2 + 1) = 14
assert result.total == 14
def test_two_shaken_conditions_do_not_double(self) -> None:
"""Two identical conditions — their untyped modifiers stack (PF1e:
same condition doesn't worsen, but our model sums untyped modifiers).
This test documents current behavior: untyped modifiers from
multiple conditions sum. A future 'condition stacking' rule could
deduplicate by condition name."""
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SHAKEN)
h.conditions.append(SHAKEN)
result = engine.resolve_attack(h, f, hero.attacks[0])
# Both -2 untyped penalties stack: 10 + 5 - 4 = 11
assert result.total == 11