fix(rules): only total concealment blocks sneak attack

PF1e (CRB rogue Sneak Attack): "A rogue cannot sneak attack while
striking a creature with total concealment." _sneak_attack_dice now gates
on miss_chance >= 50 instead of any concealment > 0, so a blurred (20%)
target can still be sneak attacked while a displaced (50%) one cannot. The
test that pinned the old any-concealment denial was retargeted to total
concealment and a partial-concealment case added; README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 23:41:06 +02:00
parent a2825e73cc
commit 9d583a04c0
3 changed files with 44 additions and 9 deletions
+7 -2
View File
@@ -859,7 +859,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 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 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é
@@ -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 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 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 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
(vase, plante) ne sont pas modélisées (pas de tels types dans le modèle). (vase, plante) ne sont pas modélisées (pas de tels types dans le modèle).
+10 -5
View File
@@ -2496,12 +2496,17 @@ 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; concealment denies the sneak attack 30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance
entirely. PF1e (CRB): undead and constructs are immune to precision >= 50%) denies the sneak attack; partial concealment (blurred 20%)
damage — ooze/plant immunity is not modeled (no such creature types does not (CRB rogue "Sneak Attack": "cannot sneak attack while
exist in the data model). 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 return 0
if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES: if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES:
return 0 return 0
+27 -2
View File
@@ -138,7 +138,8 @@ def test_total_concealment_triggers_at_50() -> None:
assert t.hp == 100 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",)) sneak = _combatant("atk", level=3, features=("Sneak Attack",))
eng = _engine( eng = _engine(
[sneak, _combatant("tgt")], [sneak, _combatant("tgt")],
@@ -148,13 +149,37 @@ def test_concealment_denies_sneak_attack() -> None:
) )
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, "displaced")
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 > 50% lands; sneak denied by total concealment,
# so only the 1d6 weapon die (3) lands.
assert result.damage == 3 assert result.damage == 3
assert t.hp == 97 assert t.hp == 97
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:
"""(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: def test_concealment_miss_does_not_consume_deed() -> None:
eng = _engine( eng = _engine(
[ [