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:
2026-09-12 13:26:52 +02:00
parent d152d42af9
commit 35c8d101b5
4 changed files with 100 additions and 12 deletions
+23
View File
@@ -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