"""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"] == ()