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:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user