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:
@@ -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"] == ()
|
||||
Reference in New Issue
Block a user