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:
@@ -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:
|
||||
"""Given: no cover and no concealment between guard and runner
|
||||
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)
|
||||
|
||||
|
||||
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) ───────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user