"""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