diff --git a/README.md b/README.md index 47054a0..64f57cb 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 743eb10..d8db617 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py new file mode 100644 index 0000000..a8002c5 --- /dev/null +++ b/src/pf1e_simulator/conditions.py @@ -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", +) diff --git a/tests/test_conditions.py b/tests/test_conditions.py new file mode 100644 index 0000000..d21a540 --- /dev/null +++ b/tests/test_conditions.py @@ -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