diff --git a/README.md b/README.md index 4c12fcf..30ec3ef 100644 --- a/README.md +++ b/README.md @@ -897,11 +897,17 @@ Règles modélisées : victime d'une attaque sournoise ; une dissimulation partielle (flou 20 %) ne bloque plus la sournoise (conformance A7 — CRB rogue « Sneak Attack » : « cannot sneak attack while striking a creature with total concealment »). - Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses - adversaires comme ayant un concealment total — le même jet unique de d100 est - tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé - « attacker blinded » ; l'attaque peut donc rater même contre une cible sans - concealment. + Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses + adversaires comme ayant un concealment total — le même jet unique de d100 est + tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé + « attacker blinded » ; l'attaque peut donc rater même contre une cible sans + concealment. Conformance L2 (CRB « Conditions — Blinded » : « All opponents + are considered to have total concealment (50% miss chance) against the + blinded character ») : la même convention `max(cible, 50)` s'applique aux + deux autres portes de concealment de l'attaquant — l'attaque sournoise est + **impossible** pour un aveugle (dissimulation totale) et ses attaques + d'opportunité sont **supprimées** contre toute cible (`suppressed (total + concealment)`, budget d'AoO non consommé). - Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses jets d'attaque et de confirmation de critique** et sa cible **perd son bonus de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course). diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d9ba4bc..36fd9eb 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2618,12 +2618,20 @@ class CombatEngine: 30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance >= 50%) denies the sneak attack; partial concealment (blurred 20%) does not (CRB rogue "Sneak Attack": "cannot sneak attack while - striking a creature with total concealment"). PF1e (CRB): undead and - constructs are immune to precision damage — ooze/plant immunity is not - modeled (no such creature types exist in the data model). + striking a creature with total concealment"). A BLINDED attacker faces + total concealment against ALL its opponents (CRB "Conditions — + Blinded": "All opponents are considered to have total concealment + (50% miss chance) against the blinded character"), so it cannot sneak + attack either — same max(target, 50) convention as _resolve_hit_damage. + PF1e (CRB): undead and constructs are immune to precision damage — + ooze/plant immunity is not modeled (no such creature types exist in + the data model). """ + effective_concealment = concealment + if self._has_condition_named(attacker, "blinded"): + effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) if ( - concealment >= _TOTAL_CONCEALMENT_PCT + effective_concealment >= _TOTAL_CONCEALMENT_PCT or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE) ): return 0 @@ -3082,10 +3090,17 @@ class CombatEngine: PF1e (CRB "Cover"): "You can't execute an attack of opportunity against an opponent with cover relative to you"; (CRB "Concealment"): "You can't execute an attack of opportunity against an opponent with - total concealment" (miss chance >= 50%). Melee line: terrain cover - only — creatures grant no melee cover. + total concealment" (miss chance >= 50%). A BLINDED attacker considers + ALL its opponents to have total concealment (CRB "Conditions — + Blinded": "All opponents are considered to have total concealment + (50% miss chance) against the blinded character"), so its AoOs are + suppressed too. Melee line: terrain cover only — creatures grant no + melee cover. """ - if self._concealment_chance(target) >= _TOTAL_CONCEALMENT_PCT: + concealment = self._concealment_chance(target) + if self._has_condition_named(attacker, "blinded"): + concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) + if concealment >= _TOTAL_CONCEALMENT_PCT: return "total concealment" if cover_bonus_cells( self._grid, diff --git a/tests/test_aoo_defense.py b/tests/test_aoo_defense.py index 00c3add..c7c65d7 100644 --- a/tests/test_aoo_defense.py +++ b/tests/test_aoo_defense.py @@ -78,6 +78,29 @@ def test_aoo_suppressed_vs_total_concealment() -> None: ) +def test_aoo_suppressed_when_attacker_is_blinded() -> None: + """Given: the guard is blinded and the runner has no concealment + When: the guard resolves the AoO + Then: it is suppressed — a blinded character's opponents all have total + concealment (CRB Conditions — Blinded) — no die, no budget spent.""" + runner = make_combatant("runner", hp=20, ac=12) + guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5) + states = [ + make_state(runner, "players", (2, 1)), + make_state(guard, "monsters", (2, 0)), + ] + engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states) + runner_s, guard_s = engine._states + _add_condition(guard_s, "blinded") + assert engine._resolve_aoo(guard_s, runner_s) is False + assert engine._aoo_used.get("guard", 0) == 0 + assert len(engine._rng._queue) == 2 + assert runner_s.hp == 20 + assert any( + "suppressed (total concealment)" in line for line in engine._transcript + ) + + def test_aoo_still_strikes_in_the_open() -> None: """Given: no cover and no concealment between guard and runner When: the guard resolves the AoO diff --git a/tests/test_conditions_blinded_invisible.py b/tests/test_conditions_blinded_invisible.py index 4d39142..b8ede46 100644 --- a/tests/test_conditions_blinded_invisible.py +++ b/tests/test_conditions_blinded_invisible.py @@ -81,6 +81,50 @@ def test_blinded_attacker_d100_rolls_once_with_target_concealment() -> None: 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) ───────────