From 679d405943efdcf778b902ada98ae4ee3d62b2b1 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:57 +0200 Subject: [PATCH] feat(combat): enforce prone/staggered/nauseated/paralyzed --- src/pf1e_simulator/combat.py | 69 +++++++- tests/test_conditions_effects.py | 287 +++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 tests/test_conditions_effects.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 69bfb32..03ed837 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -367,6 +367,10 @@ class CombatEngine: state.moved_this_turn = False state.power_attack = False actions = self._policy(self, state) + if self._has_condition_named(state, "staggered"): + actions = _staggered_filter(actions) + elif self.has_condition_flag(state, "cant_attack"): + actions = _nauseated_filter(actions) before = len(self._transcript) for action in actions: if not state.active: @@ -386,6 +390,24 @@ class CombatEngine: # Also check old conditions field for backwards compatibility return any(getattr(cond, flag, False) for cond in state.conditions) + def _has_condition_named(self, state: CombatantState, name: str) -> bool: + """Check if the state has an active or old condition with the given name.""" + return ( + any(ac.condition.name == name for ac in state.active_conditions) + or any(cond.name == name for cond in state.conditions) + ) + + def _modifier_condition_holds(self, _state: CombatantState, _condition_name: str) -> bool: + """Evaluate whether a StatModifier's runtime condition holds. + + Returns False for unknown or unresolvable conditions (modifier excluded). + Currently no conditions are evaluable from _stat_modifiers call sites + alone (they require action-level context like target range or spell + descriptor), so this always returns False. When the engine gains that + context, specific conditions can be added here. + """ + return False + def _get_speed_mult(self, state: CombatantState) -> float: """Get the minimum speed multiplier from all active conditions.""" speed_mult = 1.0 @@ -518,6 +540,11 @@ class CombatEngine: ``weapon_name`` filters out modifiers with a ``weapon_filter`` that doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol"). Modifiers without a ``weapon_filter`` are always included. + + Modifiers whose ``condition`` is set are only included when the named + runtime condition holds for ``state`` (evaluated via + ``_modifier_condition_holds``). Unsupported condition names are treated + as NOT holding (modifier excluded). """ mods: list[StatModifier] = [] # Old effects field (backwards compatibility) @@ -539,7 +566,16 @@ class CombatEngine: if m.target == target and (m.weapon_filter is None or m.weapon_filter == weapon_name) ) - return mods + if target == "ac": + if self._has_condition_named(state, "prone"): + mods.append(StatModifier(target="ac", value=-4)) + if self._has_condition_named(state, "paralyzed"): + mods.append(StatModifier(target="ac", value=-4)) + return [ + m + for m in mods + if m.condition is None or self._modifier_condition_holds(state, m.condition) + ] def _has_feat(self, state: CombatantState, feat_name: str) -> bool: """True if the combatant has a feature with the given name.""" @@ -566,6 +602,8 @@ class CombatEngine: 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 atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name) + if weapon.kind == "ranged" and self._has_condition_named(attacker, "prone"): + atk_mods.append(StatModifier(target="attack", value=-4)) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 total = roll + base_bonus + penalty + flank + attack_mod - pa_amt @@ -2299,6 +2337,35 @@ def _choose_spell_action( return None +_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"}) +_STANDARD_KINDS: frozenset[str] = frozenset({ + "attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy", +}) + + +def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: + """Staggered: one move OR one standard action per round, no full-round. + + A leading ``full_attack`` (already in range) becomes a single standard + ``attack``; charge/withdraw/full_round are dropped. + """ + for action in actions: + if action.kind == "full_attack": + return (Action(kind="attack", target_id=action.target_id),) + if action.kind in _FULL_ROUND_KINDS: + continue + return (action,) + return () + + +def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: + """Nauseated: single standard action only.""" + for action in actions: + if action.kind in _STANDARD_KINDS: + return (action,) + return () + + Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py new file mode 100644 index 0000000..c69a16f --- /dev/null +++ b/tests/test_conditions_effects.py @@ -0,0 +1,287 @@ +"""Tests for mechanical condition effects (prone, staggered, nauseated, paralyzed) +and StatModifier.condition filtering. + +Part (a): StatModifier with condition= is only included when the condition holds. +Part (b): Condition behavioral effects enforced via action economy / AC modifiers. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED +from pf1e_simulator.dice import parse_dice +from pf1e_simulator.effects import ActiveCondition, 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", bab: int = 5) -> Combatant: + melee = AttackSpec( + id=f"{cid}-sword", + name="sword", + kind="melee", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], + damage_bonus=2, + ) + ranged = AttackSpec( + id=f"{cid}-bow", + name="bow", + kind="ranged", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], + damage_bonus=2, + range_increment_ft=60, + ) + 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=bab, + initiative_mod=2, + speed_land_ft=30, + saves=Saves(fort=5, ref=4, will=3), + attacks=[melee, ranged], + ) + + +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 + + +def _make_engine_3( + c1: Combatant, c2: Combatant, c3: Combatant, queue: list[int] +) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]: + s1 = CombatantState(combatant=c1, side="players", pos=(0, 0), hp=c1.hp_max) + s2 = CombatantState(combatant=c2, side="monsters", pos=(0, 1), hp=c2.hp_max) + s3 = CombatantState(combatant=c3, side="monsters", pos=(0, 2), hp=c3.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, [s1, s2, s3]) + return engine, s1, s2, s3 + + +# ── Test 1: prone → -4 AC vs melee attacks ─────────────────────────────────── + + +class TestProneACMelee: + """Given: a prone target + When: a melee attacker resolves an attack + Then: the target's AC is reduced by 4 (prone penalty).""" + + def test_prone_target_minus4_ac_vs_melee(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + f.active_conditions.append( + ActiveCondition(condition=PRONE, rounds_left=5, source="test") + ) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # foe total AC 15, prone penalty -4 → effective AC 11 + assert result.ac == 11 + + +# ── Test 2: prone ranged attacker → -4 on ranged attack ─────────────────────── + + +class TestProneRangedAttack: + """Given: an attacker who is prone + When: they make a ranged attack + Then: their attack total is reduced by 4.""" + + def test_prone_attacker_minus4_ranged_attack(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [15, 4]) + h.active_conditions.append( + ActiveCondition(condition=PRONE, rounds_left=5, source="test") + ) + ranged_weapon = hero.attacks[1] # bow + result = engine.resolve_attack(h, f, ranged_weapon) + # base_bonus=5, roll=15, no flank (ranged), no range penalty (same cell), prone -4 + # total = 15 + 5 - 4 = 16 + assert result.total == 16 + + +# ── Test 3: staggered → reduced action economy ──────────────────────────────── + + +class TestStaggeredActionEconomy: + """Given: a staggered combatant + When: _take_turn is called + Then: they can only do one move OR one standard action (no full attack).""" + + def test_staggered_full_attack_becomes_single_attack(self) -> None: + hero = _make_hero(bab=6) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 10, 10, 4]) + h.active_conditions.append( + ActiveCondition(condition=STAGGERED, rounds_left=5, source="test") + ) + engine._roll_initiative() + engine._take_turn(h) + # Staggered: no full-round. A leading full_attack (adjacent, BAB 6 → + # 2 swings) becomes a single standard-action attack → exactly 1 swing. + swings = [e for e in engine._transcript if " vs foe " in e and "sword" in e] + assert len(swings) == 1 + + def test_staggered_can_still_act(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 10, 4]) + h.active_conditions.append( + ActiveCondition(condition=STAGGERED, rounds_left=5, source="test") + ) + engine._roll_initiative() + result = engine._take_turn(h) + assert result is False + + +# ── Test 4: nauseated → single standard action only ─────────────────────────── + + +class TestNauseatedActionEconomy: + """Given: a nauseated combatant + When: _take_turn is called + Then: they can only take standard actions (no full attack, no charge, no move).""" + + def test_nauseated_no_attack_and_no_full_round(self) -> None: + hero = _make_hero(bab=6) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 10, 4]) + h.active_conditions.append( + ActiveCondition(condition=NAUSEATED, rounds_left=5, source="test") + ) + engine._roll_initiative() + engine._take_turn(h) + for entry in engine._transcript: + assert "sword vs foe" not in entry, ( + f"nauseated combatant should not attack: {entry}" + ) + + +# ── Test 5: paralyzed → no actions + effective DEX 0 (-4 AC) ───────────────── + + +class TestParalyzedNoActions: + """Given: a paralyzed combatant + When: _take_turn is called + Then: they take no actions and are effectively helpless.""" + + def test_paralyzed_no_action(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 10, 4]) + h.active_conditions.append( + ActiveCondition(condition=PARALYZED, rounds_left=5, source="test") + ) + engine._roll_initiative() + engine._take_turn(h) + # Paralyzed: cant_act=True → should only log "wait (cannot act)" + found_wait = False + for entry in engine._transcript: + if "cannot act" in entry: + found_wait = True + # Should not have executed any attack + assert "attack" not in entry.lower() or "cannot" in entry.lower(), ( + f"paralyzed combatant should not attack: {entry}" + ) + assert found_wait, "paralyzed combatant should log 'wait (cannot act)'" + + def test_paralyzed_defender_helpless_ac(self) -> None: + """Paralyzed target: denies_dex_to_ac=True (flat-footed) + -4 AC (helpless).""" + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + f.active_conditions.append( + ActiveCondition(condition=PARALYZED, rounds_left=5, source="test") + ) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9 + assert result.ac == 9 + + +# ── Test 6: StatModifier condition filtering ────────────────────────────────── + + +class TestStatModifierConditionFiltering: + """Given: a StatModifier with condition= set + When: the condition does not hold → modifier excluded + When: the condition holds → modifier included.""" + + def test_modifier_excluded_when_condition_not_held(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + # Add a modifier with condition="mounted" that doesn't hold + h.effects.append( + StatModifier(target="attack", value=5, condition="mounted") + ) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # The +5 should NOT apply because "mounted" condition doesn't hold + # base_bonus=5, roll=10, no modifiers from condition + assert result.total == 10 + 5 # 15 + + def test_modifier_included_when_condition_holds(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + # Add a modifier with no condition (always active) + h.effects.append( + StatModifier(target="attack", value=3) + ) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # base_bonus=5, roll=10, +3 from always-active modifier + assert result.total == 10 + 5 + 3 # 18