diff --git a/README.md b/README.md index 50104e6..ea3cea1 100644 --- a/README.md +++ b/README.md @@ -859,7 +859,10 @@ Règles modélisées : cible en porte une, après la touche et la confirmation de critique, le moteur tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun dégât, pas de critique, deed non consommé — journalisé « concealment »). Une - cible avec concealment ne peut pas être la victime d'une attaque sournoise. + cible avec concealment **total** (`miss_chance ≥ 50 %`) ne peut pas être la + 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é @@ -890,7 +893,9 @@ Règles modélisées : attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une - cible avec concealment. Conformance P2 : les morts-vivants (`undead`) et les + cible à dissimulation **totale** (`miss_chance ≥ 50 %` ; conformance A7 — une + dissimulation partielle comme le flou 20 % n'empêche plus la sournoise). + Conformance P2 : les morts-vivants (`undead`) et les constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW (vase, plante) ne sont pas modélisées (pas de tels types dans le modèle). diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 9811a15..4e58a84 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2496,12 +2496,17 @@ class CombatEngine: PF1e: precision damage when the target is flanked (melee only, melee touch included) or denied its Dex bonus to AC (melee, or ranged within - 30 ft); dice = (level + 1) // 2; concealment denies the sneak attack - entirely. 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). + 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). """ - if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE): + if ( + concealment >= _TOTAL_CONCEALMENT_PCT + or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE) + ): return 0 if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES: return 0 diff --git a/tests/test_accuracy_rules.py b/tests/test_accuracy_rules.py index 29b4ecd..47c17a7 100644 --- a/tests/test_accuracy_rules.py +++ b/tests/test_accuracy_rules.py @@ -138,7 +138,8 @@ def test_total_concealment_triggers_at_50() -> None: assert t.hp == 100 -def test_concealment_denies_sneak_attack() -> None: +def test_total_concealment_denies_sneak_attack() -> None: + """(A7) total concealment (displaced 50%) denies the sneak attack.""" sneak = _combatant("atk", level=3, features=("Sneak Attack",)) eng = _engine( [sneak, _combatant("tgt")], @@ -148,13 +149,37 @@ def test_concealment_denies_sneak_attack() -> None: ) a, t = eng._states _add_condition(t, "flat_footed") - _add_condition(t, "blurred") + _add_condition(t, "displaced") result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + # d20=15 hits, d100=90 > 50% lands; sneak denied by total concealment, + # so only the 1d6 weapon die (3) lands. assert result.damage == 3 assert t.hp == 97 assert not any("sneak" in line for line in eng._transcript) +def test_partial_concealment_still_allows_sneak_attack() -> None: + """(A7) a blurred (20%) target CAN be sneak attacked — CRB rogue text is + "cannot sneak attack while striking a creature with total concealment", + so partial concealment no longer denies the precision damage.""" + sneak = _combatant("atk", level=3, features=("Sneak Attack",)) + eng = _engine( + [sneak, _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 90, 6, 6, 4], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + _add_condition(t, "blurred") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + # d20=15 hits, d100=90 > 20% lands; 2d6 sneak (6+6=12) + 1d6 weapon (4) + # = 16 precision-inclusive damage. + assert result.damage == 16 + assert t.hp == 84 + assert any("sneak attack" in line and "2d6" in line for line in eng._transcript) + + def test_concealment_miss_does_not_consume_deed() -> None: eng = _engine( [