"""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 = 20 beats AC 10 (B10 conformance: blinded carries NO # flat attack penalty — the 50% total-concealment miss below is the RAW # penalty), then d100 30 <= 50 misses. assert result.hit is True assert result.total == 20 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) def test_blinded_attacker_cannot_sneak_attack() -> None: # PF1e (CRB Conditions — Blinded): "All opponents are considered to have # total concealment (50% miss chance) against the blinded character" — and # total concealment denies sneak attack (CRB rogue "Sneak Attack"). eng = _engine( [ _combatant("atk", features=("Sneak Attack",)), _combatant("tgt"), ], [(0, 0), (0, 1)], ["players", "monsters"], [15, 51, 4], ) a, t = eng._states _add_condition(a, "blinded") _add_condition(t, "flat_footed") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # d20 15 + 5 = 20 hits; blinded d100 51 > 50 lands; base 1d6 = 4 only — # no sneak d6 is rolled despite the target being denied its Dex bonus. assert result.hit is True assert result.damage == 4 assert not any("sneak" in line for line in eng._transcript) def test_sighted_sneak_attack_control_when_not_blinded() -> None: eng = _engine( [ _combatant("atk", features=("Sneak Attack",)), _combatant("tgt"), ], [(0, 0), (0, 1)], ["players", "monsters"], [15, 3, 4], ) a, t = eng._states _add_condition(t, "flat_footed") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) # Same swing without blinded: sneak applies (d20 15 hits, sneak d6 3, # base d6 4 -> 7 total). assert result.hit is True assert result.damage == 7 assert any("sneak attack" 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 minus # the Dex-0 penalty (helpless target has effective Dexterity 0: -5). assert result.total == 19 assert result.ac == 5 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 >= 5 (flat-footed 10 - 5 Dex-0) -> 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