fix(rules): treat a blinded attacker as facing total concealment
CRB 'Conditions - Blinded': 'All opponents are considered to have total concealment (50% miss chance) against the blinded character.' The sneak-attack gate and the AoO suppression gate now apply the same max(target_concealment, 50) convention as _resolve_hit_damage: a blinded attacker cannot sneak attack and its AoOs are suppressed without spending the budget. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -901,7 +901,13 @@ Règles modélisées :
|
|||||||
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é
|
||||||
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
|
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
|
||||||
concealment.
|
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
|
- Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses
|
||||||
jets d'attaque et de confirmation de critique** et sa cible **perd son bonus
|
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).
|
de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course).
|
||||||
|
|||||||
@@ -2618,12 +2618,20 @@ class CombatEngine:
|
|||||||
30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance
|
30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance
|
||||||
>= 50%) denies the sneak attack; partial concealment (blurred 20%)
|
>= 50%) denies the sneak attack; partial concealment (blurred 20%)
|
||||||
does not (CRB rogue "Sneak Attack": "cannot sneak attack while
|
does not (CRB rogue "Sneak Attack": "cannot sneak attack while
|
||||||
striking a creature with total concealment"). PF1e (CRB): undead and
|
striking a creature with total concealment"). A BLINDED attacker faces
|
||||||
constructs are immune to precision damage — ooze/plant immunity is not
|
total concealment against ALL its opponents (CRB "Conditions —
|
||||||
modeled (no such creature types exist in the data model).
|
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 (
|
if (
|
||||||
concealment >= _TOTAL_CONCEALMENT_PCT
|
effective_concealment >= _TOTAL_CONCEALMENT_PCT
|
||||||
or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE)
|
or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE)
|
||||||
):
|
):
|
||||||
return 0
|
return 0
|
||||||
@@ -3082,10 +3090,17 @@ class CombatEngine:
|
|||||||
PF1e (CRB "Cover"): "You can't execute an attack of opportunity
|
PF1e (CRB "Cover"): "You can't execute an attack of opportunity
|
||||||
against an opponent with cover relative to you"; (CRB "Concealment"):
|
against an opponent with cover relative to you"; (CRB "Concealment"):
|
||||||
"You can't execute an attack of opportunity against an opponent with
|
"You can't execute an attack of opportunity against an opponent with
|
||||||
total concealment" (miss chance >= 50%). Melee line: terrain cover
|
total concealment" (miss chance >= 50%). A BLINDED attacker considers
|
||||||
only — creatures grant no melee cover.
|
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"
|
return "total concealment"
|
||||||
if cover_bonus_cells(
|
if cover_bonus_cells(
|
||||||
self._grid,
|
self._grid,
|
||||||
|
|||||||
@@ -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:
|
def test_aoo_still_strikes_in_the_open() -> None:
|
||||||
"""Given: no cover and no concealment between guard and runner
|
"""Given: no cover and no concealment between guard and runner
|
||||||
When: the guard resolves the AoO
|
When: the guard resolves the AoO
|
||||||
|
|||||||
@@ -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)
|
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) ───────────
|
# ── Rule B: invisible attacker (+2 to hit, target loses Dex to AC) ───────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user