"""V5a conditions: blinded-attacker miss, invisible attacker, helpless, fascinated. Canonical PF1e (Core Rulebook, Conditions): a blinded character's opponents have total concealment against it (50% miss); an invisible attacker gains +2 on attack rolls and its target loses its Dexterity bonus to AC, while the invisible creature itself has total concealment; melee attacks against a helpless target gain +4 (ranged none); a fascinated creature cannot act. """ from __future__ import annotations from pf1e_simulator.conditions import ( CONDITIONS_BY_NAME, FASCINATED, HELPLESS, INVISIBLE, ) from pf1e_simulator.models import AbilityScores, ACProfile, Combatant, Saves from tests.test_accuracy_rules import ( _add_condition, _bow, _combatant, _engine, _melee, ) from tests.test_observation_enriched import _snapshot # ── Rule A: blinded attacker suffers total concealment (50% miss) ──────────── def test_blinded_attacker_misses_on_low_d100() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 30], ) a, t = eng._states _add_condition(a, "blinded") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # roll 15 + bonus 5 - 2 (blinded) = 18 beats AC 10, then d100 30 <= 50 misses. assert result.hit is True assert result.total == 18 assert result.damage == 0 assert t.hp == 100 assert any("attacker blinded" in line and "misses" in line for line in eng._transcript) def test_blinded_attacker_lands_on_high_d100() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 51, 4], ) a, t = eng._states _add_condition(a, "blinded") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.hit is True assert result.damage == 4 assert t.hp == 96 assert any("attacker blinded" in line and "lands" in line for line in eng._transcript) def test_blinded_attacker_d100_rolls_once_with_target_concealment() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 30], ) a, t = eng._states _add_condition(a, "blinded") _add_condition(t, "blurred") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # max(20, 50) = 50; a single d100 (30) consumes the queue exactly. assert result.hit is True assert result.damage == 0 assert any("50%" in line for line in eng._transcript) # ── Rule B: invisible attacker (+2 to hit, target loses Dex to AC) ─────────── def _dexterous_foe(cid: str) -> Combatant: """A foe whose AC profile bakes in a +3 Dexterity bonus (RAW-style totals).""" return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=16, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=100, ac=ACProfile(total=18, touch=15, flat_footed=15), bab=1, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=0, ref=0, will=0), attacks=[_melee(cid)], ) def test_invisible_attacker_gains_plus2_and_denies_dex_ac() -> None: eng = _engine( [_combatant("atk"), _dexterous_foe("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [10, 4], ) a, t = eng._states _add_condition(a, "invisible") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # roll 10 + bonus 5 + 2 (invisible) = 17 vs flat-footed AC 15 (Dex denied). assert result.total == 17 assert result.ac == 15 assert result.hit is True assert result.damage == 4 def test_visible_attacker_normal_attack_control() -> None: eng = _engine( [_combatant("atk"), _dexterous_foe("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [10], ) a, t = eng._states result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # No invisible: total 15 < full AC 18 -> miss, no damage roll consumed. assert result.total == 15 assert result.ac == 18 assert result.hit is False def test_invisible_attacker_plus2_applies_to_crit_confirmation() -> None: eng = _engine( [_combatant("atk"), _dexterous_foe("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [20, 10, 4], ) a, t = eng._states _add_condition(a, "invisible") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # Nat 20 threatens; confirm 10 + 5 + 2 = 17 >= 15 -> crit. Damage 4 x 2. assert result.hit is True assert result.crit is True assert result.damage == 8 # ── Rule C: melee +4 vs a helpless target (ranged none) ────────────────────── def test_melee_vs_helpless_target_gains_plus4() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [10, 4], ) a, t = eng._states _add_condition(t, "helpless") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 (Dex denied). assert result.total == 19 assert result.ac == 10 assert result.hit is True def test_melee_vs_helpless_plus4_applies_to_crit_confirmation() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [20, 10, 4], ) a, t = eng._states _add_condition(t, "helpless") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # Confirm 10 + 5 + 4 = 19 >= 10 -> crit. assert result.crit is True assert result.damage == 8 def test_ranged_vs_helpless_target_gets_no_bonus() -> None: eng = _engine( [_combatant("atk", attacks=(_bow("atk"),)), _combatant("tgt")], [(0, 0), (0, 4)], ["players", "monsters"], [10, 4], ) a, t = eng._states _add_condition(t, "helpless") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # Ranged: no +4 — roll 10 + bonus 5 = 15. assert result.total == 15 assert result.hit is True # ── Rule D: fascinated / helpless combatants cannot act ────────────────────── def test_fascinated_combatant_turn_is_noop() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) a, _b = eng._states _add_condition(a, "fascinated") eng._take_turn(a) # An empty RNG queue was not consumed: the turn made no attack roll. assert any("a: wait (cannot act)" in line for line in eng._transcript) assert sum("attack" in line for line in eng._transcript) == 0 def test_helpless_combatant_turn_is_noop() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) a, _b = eng._states _add_condition(a, "helpless") eng._take_turn(a) assert any("a: wait (cannot act)" in line for line in eng._transcript) assert sum("attack" in line for line in eng._transcript) == 0 # ── Rule E: attacks against an invisible target have a 50% miss chance ─────── def test_attack_vs_invisible_target_misses_on_low_d100() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 30], ) a, t = eng._states _add_condition(t, "invisible") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # Hit (15 + 5 = 20 >= 10) but d100 30 <= 50 total concealment -> miss. assert result.hit is True assert result.damage == 0 assert t.hp == 100 assert any( "concealment vs tgt" in line and "50%" in line and "misses" in line for line in eng._transcript ) def test_attack_vs_invisible_target_lands_on_high_d100() -> None: eng = _engine( [_combatant("atk"), _combatant("tgt")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 51, 4], ) a, t = eng._states _add_condition(t, "invisible") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.hit is True assert result.damage == 4 assert t.hp == 96 # ── Rule F: registration + snapshot exposure ───────────────────────────────── def test_new_conditions_registered() -> None: assert CONDITIONS_BY_NAME["helpless"] is HELPLESS assert CONDITIONS_BY_NAME["fascinated"] is FASCINATED assert CONDITIONS_BY_NAME["invisible"] is INVISIBLE assert HELPLESS.cant_act is True assert HELPLESS.denies_dex_to_ac is True assert FASCINATED.cant_act is True assert INVISIBLE.miss_chance == 50 def test_snapshot_exposes_new_condition_definitions() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 3)], ["players", "monsters"], [], ) obs = _snapshot(eng, eng._states[0]) defs = dict(obs["condition_definitions"]) assert len(defs) == len(CONDITIONS_BY_NAME) assert defs["helpless"]["flags"] == ["denies_dex_to_ac", "cant_act"] assert defs["fascinated"]["flags"] == ["cant_act"] assert defs["invisible"]["miss_chance"] == 50