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 Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) : dans la résolution) :
- Sorts et conditions (le système d'effets `effects.py`, les règles - Sorts (le système d'effets `effects.py`, les règles d'empilement des
d'empilement des bonus et les jets de sauvegarde `resolve_save` sont en bonus, les jets de sauvegarde `resolve_save` et le système de conditions
place — l'intégration des sorts et conditions au moteur est en cours). `conditions.py` sont en place — l'intégration des sorts 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
@@ -370,6 +371,13 @@ dans la résolution) :
sans type) s'additionnent ; types non-cumulables (morale, sacré, profane, sans type) s'additionnent ; types non-cumulables (morale, sacré, profane,
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les 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. 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`, - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]). `win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py``EncounterSpec`/`Side`, `build_states` (placement en zone + - `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) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 253 tests uv run pytest -q # 276 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
``` ```
+20 -8
View File
@@ -84,6 +84,7 @@ from pf1e_simulator.effects import StatModifier, resolve_modifiers
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Literal from typing import Literal
from pf1e_simulator.conditions import Condition
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos from pf1e_simulator.map import Pos
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
@@ -159,6 +160,7 @@ class CombatantState:
pos: Pos pos: Pos
hp: int hp: int
effects: list[StatModifier] = field(default_factory=list) effects: list[StatModifier] = field(default_factory=list)
conditions: list[Condition] = field(default_factory=list)
moved_this_turn: bool = False moved_this_turn: bool = False
@property @property
@@ -355,6 +357,13 @@ class CombatEngine:
return _FLANK_BONUS return _FLANK_BONUS
return 0 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( def resolve_attack(
self, self,
attacker: CombatantState, attacker: CombatantState,
@@ -369,10 +378,10 @@ class CombatEngine:
penalty = self.range_penalty(weapon, dist_ft) penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 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 base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
total = roll + base_bonus + penalty + flank atk_mods = self._stat_modifiers(attacker, "attack")
ac = target.combatant.ac.total + resolve_modifiers( attack_mod = resolve_modifiers(atk_mods)
e for e in target.effects if e.target == "ac" total = roll + base_bonus + penalty + flank + attack_mod
) ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
occupied = frozenset( occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target 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 hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range: if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20() 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 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 = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
if crit: if crit:
damage *= weapon.crit_mult damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target) damage = self._apply_dr(damage, weapon, target)
@@ -417,12 +428,13 @@ class CombatEngine:
"""Roll a saving throw (fort/ref/will) against ``dc``. """Roll a saving throw (fort/ref/will) against ``dc``.
PF1e: natural 1 = automatic failure, natural 20 = automatic success. PF1e: natural 1 = automatic failure, natural 20 = automatic success.
Effect modifiers (StatModifier with matching target) are applied via Effect modifiers and condition modifiers (StatModifier with matching
``resolve_modifiers`` — same bonus-type stacking rules as attacks. target) are applied via ``resolve_modifiers`` — same bonus-type
stacking rules as attacks.
""" """
roll = self._rng.d20() roll = self._rng.d20()
base = getattr(state.combatant.saves, save_type) 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 total = roll + base + bonus
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
return SaveResult(success=success, roll=roll, total=total, dc=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