fix(rules): any concealment denies sneak attack (SRD rogue: 'a creature with concealment')

This commit is contained in:
2026-09-16 19:50:40 +02:00
parent 4d45409941
commit 2807f719ca
3 changed files with 28 additions and 25 deletions
+7 -6
View File
@@ -1149,10 +1149,10 @@ Règles modélisées :
cible en porte une, après la touche et la confirmation de critique, le moteur 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 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 dégât, pas de critique, deed non consommé — journalisé « concealment »). Une
cible avec concealment **total** (`miss_chance ≥ 50 %`) ne peut pas être la cible avec **n'importe quelle dissimulation** (`miss_chance > 0` — flou 20 %
victime d'une attaque sournoise ; une dissimulation partielle (flou 20 %) ne comme dissimulation totale 50 %) ne peut pas être la victime d'une attaque
bloque plus la sournoise (conformance A7 — CRB rogue « Sneak Attack » : sournoise (conformance A7 — SRD rogue « Sneak Attack » :
« cannot sneak attack while striking a creature with total concealment »). « A rogue cannot sneak attack while striking a creature with concealment »).
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses 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 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é tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
@@ -1189,8 +1189,9 @@ Règles modélisées :
attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et 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 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 multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une
cible à dissimulation **totale** (`miss_chance ≥ 50 %` ; conformance A7 — une cible qui a une dissimulation, même partielle (`miss_chance > 0` ;
dissimulation partielle comme le flou 20 % n'empêche plus la sournoise). conformance A7 — SRD rogue : « A rogue cannot sneak attack while striking
a creature with concealment »).
Conformance P2 : les morts-vivants (`undead`) et les Conformance P2 : les morts-vivants (`undead`) et les
constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible 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 ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW
+10 -9
View File
@@ -2999,14 +2999,15 @@ class CombatEngine:
PF1e: precision damage when the target is flanked (melee only, melee PF1e: precision damage when the target is flanked (melee only, melee
touch included) or denied its Dex bonus to AC (melee, or ranged within touch included) or denied its Dex bonus to AC (melee, or ranged within
30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance 30 ft); dice = (level + 1) // 2. ANY concealment (miss chance > 0)
>= 50%) denies the sneak attack; partial concealment (blurred 20%) denies the sneak attack (SRD rogue "Sneak Attack": "A rogue cannot
does not (CRB rogue "Sneak Attack": "cannot sneak attack while sneak attack while striking a creature with concealment" — the rogue
striking a creature with total concealment"). A BLINDED attacker faces must see the target well enough to pick a vital spot). A BLINDED
total concealment against ALL its opponents (CRB "Conditions — attacker faces total concealment against ALL its opponents (CRB
Blinded": "All opponents are considered to have total concealment "Conditions — Blinded": "All opponents are considered to have total
(50% miss chance) against the blinded character"), so it cannot sneak concealment (50% miss chance) against the blinded character"), so it
attack either — same max(target, 50) convention as _resolve_hit_damage. cannot sneak attack either — same max(target, 50) convention as
_resolve_hit_damage.
PF1e (CRB): undead and constructs are immune to precision damage — PF1e (CRB): undead and constructs are immune to precision damage —
ooze/plant immunity is not modeled (no such creature types exist in ooze/plant immunity is not modeled (no such creature types exist in
the data model). the data model).
@@ -3015,7 +3016,7 @@ class CombatEngine:
if self._has_condition_named(attacker, "blinded"): if self._has_condition_named(attacker, "blinded"):
effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
if ( if (
effective_concealment >= _TOTAL_CONCEALMENT_PCT effective_concealment > 0
or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE) or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE)
): ):
return 0 return 0
+11 -10
View File
@@ -158,26 +158,27 @@ def test_total_concealment_denies_sneak_attack() -> None:
assert not any("sneak" in line for line in eng._transcript) assert not any("sneak" in line for line in eng._transcript)
def test_partial_concealment_still_allows_sneak_attack() -> None: def test_partial_concealment_denies_sneak_attack() -> None:
"""(A7) a blurred (20%) target CAN be sneak attacked — CRB rogue text is """(A7) a blurred (20%) target CANNOT be sneak attacked — SRD rogue text
"cannot sneak attack while striking a creature with total concealment", is "A rogue cannot sneak attack while striking a creature with
so partial concealment no longer denies the precision damage.""" concealment": ANY concealment denies the precision damage (the rogue
must see the target well enough to pick out a vital spot)."""
sneak = _combatant("atk", level=3, features=("Sneak Attack",)) sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine( eng = _engine(
[sneak, _combatant("tgt")], [sneak, _combatant("tgt")],
[(0, 0), (0, 1)], [(0, 0), (0, 1)],
["players", "monsters"], ["players", "monsters"],
[15, 90, 6, 6, 4], [15, 90, 6],
) )
a, t = eng._states a, t = eng._states
_add_condition(t, "flat_footed") _add_condition(t, "flat_footed")
_add_condition(t, "blurred") _add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) 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) # d20=15 hits, d100=90 > 20% lands; sneak denied by the 20% concealment,
# = 16 precision-inclusive damage. # so only the 1d6 weapon die (6) lands.
assert result.damage == 16 assert result.damage == 6
assert t.hp == 84 assert t.hp == 94
assert any("sneak attack" in line and "2d6" in line for line in eng._transcript) assert not any("sneak" in line for line in eng._transcript)
def test_concealment_miss_does_not_consume_deed() -> None: def test_concealment_miss_does_not_consume_deed() -> None: