feat(rules): Stealth + opposed Perception - opt-in hide move action (Stealth d20 + trained class + ACP + size mod vs each enemy Perception with -1/10ft distance; observed creatures cannot be hidden from), per-observer hidden_from state treated as total concealment + unseen-attacker +2/deny-Dex for failed observers, attacking or running/charging breaks stealth, move/5-foot-step/withdraw re-check the opposed roll, observation exposes hidden_from (SRD Stealth/Perception/Skill Checks), 13 tests

This commit is contained in:
2026-09-20 21:39:54 +02:00
parent 61686b5f4e
commit 2c40994610
3 changed files with 601 additions and 13 deletions
+324
View File
@@ -0,0 +1,324 @@
"""PF1e Stealth + opposed Perception (SRD "Stealth" / "Perception").
Verified sources:
- KB rules/ultimate-intrigue/perception-and-stealth.md: "a creature using
Stealth (which calls for an opposed Perception check)"; CRB quoted there:
perceiving creatures that "fail to beat a sneaking character's Stealth check
result are not aware of the sneaking character"; "characters can't use
Stealth while being observed... finding cover or concealment allows you to
use Stealth".
- KB rules/pathfinder-unchained/perception-wis.md (notice table): "Notice a
creature using Stealth: Opposed by Stealth"; "Distance to the source,
object, or creature: +1 per 10 feet" (DC modifier = -1/10 ft on the
observer's check). The table's "distracted +5" is NOT applied: no SRD rule
makes in-combat observers automatically distracted and the model has no
distracted state (documented out of scope).
- KB rules/prpg-core-rulebook/skill-checks.md: opposed checks - "the attempt
is successful if your check result exceeds the result of the target"; no
natural 1/20 auto rules on skill checks.
- SRD Stealth (d20pfsrd, CRB text): hidden creatures are treated as having
TOTAL CONCEALMENT vs unaware observers; the unseen attacker gets +2 and
denies the defender its Dex bonus; "Your Stealth immediately ends after
you make an attack roll"; "impossible to use Stealth while attacking,
running, or charging"; size modifiers (Small +4 ... Large -4); moving
faster than half speed takes -5.
Engine: `Action(kind="hide")` (move action) runs the opposed check; the
per-observer hidden state lives in `CombatantState.hidden_from`.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
SkillRank,
)
from pf1e_simulator.rng import ScriptedRng
def _sword(cid: str, bonus: int = 3) -> AttackSpec:
return AttackSpec(
id=f"{cid}-sword",
name="short sword",
kind="melee",
attack_bonus=bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _combatant(
cid: str,
*,
size: str = "Medium",
dex: int = 10,
wis: int = 10,
stealth: SkillRank | None = None,
perception: SkillRank | None = None,
ac: int = 15,
touch: int = 10,
flat: int = 10,
) -> Combatant:
skills: dict[str, SkillRank] = {}
if stealth is not None:
skills["stealth"] = stealth
if perception is not None:
skills["perception"] = perception
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
int_score=10, wis_score=wis, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=ac, touch=touch, flat_footed=flat),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[_sword(cid)],
skills=skills,
)
def _engine(
rng: list[int],
placements: list[tuple[Combatant, tuple[int, int], str]],
) -> CombatEngine:
states = [
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
for c, pos, side in placements
]
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return CombatEngine(ScriptedRng(rng), Grid.from_spec(spec), states)
def _state(eng: CombatEngine, cid: str) -> CombatantState:
return next(s for s in eng._states if s.combatant.id == cid)
def _blind(state: CombatantState) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["blinded"], rounds_left=5, source="test"
)
)
_ROGUE_SKILL = SkillRank(rank=4, class_skill=True)
# ── the opposed check ─────────────────────────────────────────────────────────
def test_hide_opposed_check_hides_from_observer_that_fails_to_beat_it() -> None:
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
goblin = _combatant("goblin", wis=8, perception=SkillRank(rank=1, class_skill=True))
# Blinded observer cannot see it: the opposed (hearing) check applies.
eng = _engine([10, 5], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
_blind(eng._states[1])
s = _state(eng, "rogue")
eng._current_round = 1
result = eng._execute(s, Action(kind="hide"))
assert result.ok
assert result.reason == "ok"
# Stealth: 10 + 4 ranks + 4 Dex + 3 class = 21. Perception: 5 + 1 + 3
# class - 1 Wis - 1 (10 ft distance) = 7. 21 beats 7 -> hidden.
assert s.hidden_from == {"goblin"}
assert any(
"hide stealth d20=10+11=21 vs goblin perception d20=5+2=7 -> hidden"
in ln
for ln in eng._transcript
)
def test_hide_fails_when_observer_beats_stealth() -> None:
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
goblin = _combatant("goblin", wis=14, perception=SkillRank(rank=5, class_skill=True))
eng = _engine([1, 20], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
_blind(eng._states[1])
s = _state(eng, "rogue")
eng._current_round = 1
eng._execute(s, Action(kind="hide"))
# Stealth 1+11=12 vs Perception 20+5+3+2-1=29: noticed (no auto-20 on a
# skill check is irrelevant here - the total wins outright).
assert s.hidden_from == set()
assert any("-> noticed" in ln for ln in eng._transcript)
def test_no_auto_success_or_fail_on_the_skill_check() -> None:
# CRB "Skill Checks": the opposed attempt succeeds only if it EXCEEDS the
# target's result - a natural 20 on Stealth that ties the Perception
# total is not an auto-success, and a natural 1 with a big bonus is not
# an auto-fail.
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
goblin = _combatant("goblin", wis=18, perception=SkillRank(rank=5, class_skill=True))
eng = _engine([20, 20], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
_blind(eng._states[1])
s = _state(eng, "rogue")
eng._current_round = 1
eng._execute(s, Action(kind="hide"))
# 20+11=31 vs 20+5+3+4-1=31: the attempt did not exceed -> noticed.
assert s.hidden_from == set()
def test_observed_creature_cannot_hide() -> None:
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
goblin = _combatant("goblin")
# Open arena, line of effect, no cover, sighted observer: the hider is
# directly observed -> Stealth is impossible against it (no roll needed).
eng = _engine([10], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
s = _state(eng, "rogue")
eng._current_round = 1
eng._execute(s, Action(kind="hide"))
assert s.hidden_from == set()
assert any(
"hide vs goblin -> observed (Stealth impossible)" in ln
for ln in eng._transcript
)
def test_stealth_carries_the_size_modifier() -> None:
rogue = _combatant(
"rogue", size="Small", dex=18, stealth=_ROGUE_SKILL
)
goblin = _combatant("goblin", wis=8)
eng = _engine([10, 1], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
_blind(eng._states[1])
s = _state(eng, "rogue")
eng._current_round = 1
eng._execute(s, Action(kind="hide"))
# Small: +4 size bonus on top of 10 + 4 + 4 + 3 = 21 -> 25.
assert any("hide stealth d20=10+15=25" in ln for ln in eng._transcript)
# ── the hidden creature is invisible to the failed observers ─────────────────
def test_hidden_attacker_gets_unseen_bonus_and_denies_target_dex() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin", ac=20, touch=10, flat=10)
# Attack roll d20=10, then the 1d6 damage die.
eng = _engine([10, 3], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
weapon = rogue.attacks[0]
result = eng.resolve_attack(s, eng._states[1], weapon)
# Unseen attacker: +2 rides the roll; the target is denied its Dex bonus
# so the flat-footed AC 10 applies (not 20).
assert result.total == 10 + 3 + 2
assert result.ac == 10
assert result.hit
def test_attacking_breaks_stealth() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
eng = _engine([10, 1], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
eng.resolve_attack(s, eng._states[1], rogue.attacks[0])
assert s.hidden_from == set()
def test_unaware_attacker_faces_total_concealment_vs_hidden_creature() -> None:
rogue = _combatant("rogue", dex=18, ac=10, touch=10, flat=10)
goblin = _combatant("goblin", ac=10)
# Attack roll hits (d20=10+3 >= 10), then the d100 concealment roll: 30
# <= 50 -> the unaware attacker misses the hidden creature entirely.
eng = _engine([10, 30], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
result = eng.resolve_attack(eng._states[1], s, goblin.attacks[0])
assert result.hit
assert result.damage == 0
assert s.hp == s.combatant.hp_max
assert any("concealment vs rogue" in ln for ln in eng._transcript)
def test_unaware_enemy_gets_no_aoop_against_a_hidden_mover() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
# AoO attack roll would consume a die if taken; none is available.
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
eng._current_round = 1
eng._execute(s, Action(kind="move", destination=(0, 3)))
assert any(
"AoO vs rogue suppressed (total concealment)" in ln
for ln in eng._transcript
)
# ── stealth lifecycle ────────────────────────────────────────────────────────
def test_moving_while_hidden_rechecks_and_open_ground_reveals() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 4), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
eng._current_round = 1
eng._execute(s, Action(kind="move", destination=(0, 2)))
# The goblin directly observes the new position -> aware, no roll consumed.
assert s.hidden_from == set()
assert any(
"no longer hidden from goblin (observed)" in ln for ln in eng._transcript
)
def test_run_breaks_stealth_without_a_check() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 5), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
eng._current_round = 1
eng._execute(s, Action(kind="run", destination=(0, 3)))
assert s.hidden_from == set()
assert any("stealth broken (running)" in ln for ln in eng._transcript)
def test_charge_breaks_stealth_without_a_check() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
eng = _engine([10, 2], [(rogue, (0, 0), "players"), (goblin, (0, 3), "monsters")])
s = _state(eng, "rogue")
s.hidden_from = {"goblin"}
eng._current_round = 1
eng._execute(s, Action(kind="charge", target_id="goblin"))
assert any("stealth broken (charging)" in ln for ln in eng._transcript)
# ── observation contract ─────────────────────────────────────────────────────
def test_observation_exposes_hidden_from() -> None:
rogue = _combatant("rogue", dex=18)
goblin = _combatant("goblin")
eng = _engine([10, 5], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
_blind(eng._states[1])
s = _state(eng, "rogue")
eng._current_round = 1
eng._execute(s, Action(kind="hide"))
obs = eng._build_observation(s)
me = next(c for c in obs.combatants if c["id"] == "rogue")
assert me["hidden_from"] == ("goblin",)
foe = next(c for c in obs.combatants if c["id"] == "goblin")
assert foe["hidden_from"] == ()