"""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 move action only ─────────────────────────────── class TestNauseatedActionEconomy: """Given: a nauseated combatant When: _take_turn is called Then: they can only take a single move action (no standard, no attack, no spellcasting — CRB nauseated).""" 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 is helpless: denies_dex_to_ac (flat-footed) + Dex 0 (-5).""" 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; helpless Dex 0 → -5; # melee attacker also gains the +4 helpless bonus (roll 10 + 5 + 4 = 19). assert result.ac == 8 assert result.total == 19 # ── 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