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
+275 -12
View File
@@ -272,6 +272,17 @@ _TUMBLE_SPACE_ENTRY_DC = 5
_TUMBLE_EXTRA_OPPONENT_DC = 2
# SRD "Acrobatics" - Tumble: "You move at half speed when you tumble".
_TUMBLE_SPEED_FACTOR = 0.5
# PF1e (SRD "Stealth" - Modifiers): size bonus/penalty on Stealth checks.
_STEALTH_SIZE_MOD: dict[str, int] = {
"fine": 16, "diminutive": 12, "tiny": 8, "small": 4, "medium": 0,
"large": -4, "huge": -8, "gargantuan": -12, "colossal": -16,
}
# PF1e (SRD "Perception"): notice checks take -1 per 10 feet of distance.
_STEALTH_DISTANCE_FT = 10
# SRD "Stealth": moving faster than half speed takes -5 on the Stealth check
# (engine: a move/withdraw action's budget is the full speed, so its re-check
# carries the -5; see _stealth_after_move).
_STEALTH_MOVE_PENALTY = -5
_HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5
@@ -294,6 +305,9 @@ _DISABLED_MOVE_KINDS: frozenset[str] = frozenset(
"wait",
"retrieve_weapon",
"repair_weapon",
# CRB "Disabled" lists "moving 5 feet or more" as strenuous; hiding
# in place is a move action that does not move - not strenuous.
"hide",
}
)
# PF1e (CRB, Combat Maneuver): attempting a combat maneuver provokes an
@@ -1029,6 +1043,16 @@ class CombatantState:
# combatant while the target lives and stands in reach, dissipated when
# this combatant casts another spell. None when no charge is held.
held_charge: HeldCharge | None = None
# PF1e Stealth (SRD "Stealth": "Creatures that fail to beat your Stealth
# check are not aware of you and treat you as if you had total
# concealment", KB rules/ultimate-intrigue/perception-and-stealth.md):
# the ids of observers that currently cannot perceive this combatant -
# per-observer invisibility, which the global condition machinery cannot
# express (a creature can be hidden from one foe and seen by another).
# Set by the `hide` action's opposed check, re-rolled when the creature
# hides or moves again, and cleared the moment the creature makes an
# attack roll (SRD "Breaking Stealth").
hidden_from: set[str] = field(default_factory=set)
def usable_attacks(self) -> list[AttackSpec]:
# PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken
@@ -1359,7 +1383,10 @@ class Action:
Combat Casting) or the spell fizzles. ``move`` accepts ``tumble``: the
move happens at half speed and each opponent crossed is passed with one
Acrobatics check against its CMD (SRD "Acrobatics" - Tumble; see the
``tumble`` field comment).
``tumble`` field comment). ``hide`` is a move action: a Stealth check
opposed by each enemy observer's Perception check (distance -1 per 10
ft); observers that fail cannot perceive the creature until it attacks
or moves again (SRD "Stealth" - see ``_execute_hide``).
"""
kind: Literal[
@@ -1369,7 +1396,7 @@ class Action:
"inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "ready",
"total_defense", "fight_defensively", "stand_up", "drop_prone",
"aid_another", "run",
"aid_another", "run", "hide",
]
target_id: str | None = None
spell_name: str | None = None
@@ -1852,6 +1879,10 @@ class CombatEngine:
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
"carried_items": tuple(state.carried_items),
"grappled_with": state.grappled_with,
# SRD "Stealth": ids of observers that currently cannot perceive
# this combatant (they failed the opposed Perception check); the
# policy sees who is hidden from whom (per-observer invisibility).
"hidden_from": tuple(sorted(state.hidden_from)),
"moved_this_turn": state.moved_this_turn,
"power_attack": state.power_attack,
"defensive_ac_bonus": state.defensive_ac_bonus,
@@ -3407,8 +3438,16 @@ class CombatEngine:
if self._has_condition_named(attacker, "prone"):
atk_mods.append(StatModifier(target="attack", value=-4))
# PF1e: an invisible attacker strikes from an unknown direction (+2 on
# attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"):
# attack and crit confirmation, target loses its Dex bonus to AC). A
# creature hidden from its target by a successful Stealth check strikes
# as an unseen attacker exactly the same way (SRD "Stealth": observers
# that fail the check "are not aware of you and treat you as if you
# had total concealment" - CRB "Unseen Attackers and Targets" gives
# the unseen attacker +2 and denies the defender its Dex bonus).
attacker_unseen = self._has_condition_named(
attacker, "invisible"
) or self._is_hidden_from(attacker, target)
if attacker_unseen:
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e (CRB): melee attacks (melee touch included) against a helpless
# target gain +4; the helpless state includes paralyzed, asleep and
@@ -3434,11 +3473,12 @@ class CombatEngine:
# loses its DEX bonus too (U5e), reusing the same AC path — unless it
# has the Run feat (SRD Run: "You lose any Dexterity bonus to AC
# unless you have the Run feat"). An invisible attacker also denies
# the target's DEX bonus to AC.
# the target's DEX bonus to AC, and so does one hidden from the
# target by Stealth (attacker_unseen above).
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target)
or self._has_condition_named(attacker, "invisible")
or attacker_unseen
)
ac = self._attack_ac(attacker, target, weapon, dist_ft, denies_dex=denies_dex)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
@@ -3495,6 +3535,13 @@ class CombatEngine:
else:
splash_total = self._splash_scatter(attacker, target, weapon)
attacker.aid_attack_bonus = 0
# SRD "Stealth" - Breaking Stealth: "Your Stealth immediately ends
# after you make an attack roll, whether or not the attack is
# successful" (KB rules/ultimate-intrigue/perception-and-stealth.md
# awareness states: attacking makes the observers aware of you) -
# the roll above already benefited from the unseen-attacker treatment
# and the target's failed check, so the hidden state is spent now.
attacker.hidden_from.clear()
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
@@ -3603,7 +3650,7 @@ class CombatEngine:
crit = confirm == _NATURAL_TWENTY or (
confirm != _NATURAL_ONE and confirm_total >= ac
)
concealment = self._concealment_chance(target)
concealment = self._concealment_chance_vs(attacker, target)
# PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%.
attacker_blinded = self._has_condition_named(attacker, "blinded")
@@ -3819,6 +3866,28 @@ class CombatEngine:
chances += [cond.miss_chance for cond in target.conditions]
return max((c for c in chances if c > 0), default=0)
def _is_hidden_from(self, subject: CombatantState, observer: CombatantState) -> bool:
"""True when ``subject`` is hidden from ``observer`` (failed Stealth)."""
return observer.combatant.id in subject.hidden_from
def _concealment_chance_vs(
self, observer: CombatantState, target: CombatantState
) -> int:
"""Concealment the ``target`` has against attacks from ``observer``.
PF1e (SRD "Stealth", KB rules/ultimate-intrigue/perception-and-stealth.md
quoting the CRB: creatures that fail the opposed Perception check "are
not aware of the sneaking character"): a hidden creature is treated as
if it had TOTAL concealment (50% miss chance, CRB "Concealment") -
but only relative to the observers that failed the check, which the
global condition machinery cannot express, hence the per-observer
``hidden_from`` set folded in here.
"""
chance = self._concealment_chance(target)
if self._is_hidden_from(target, observer):
return max(chance, _TOTAL_CONCEALMENT_PCT)
return chance
def _sneak_attack_dice(
self,
attacker: CombatantState,
@@ -4398,7 +4467,7 @@ class CombatEngine:
suppressed too. Melee line: terrain cover only — creatures grant no
melee cover.
"""
concealment = self._concealment_chance(target)
concealment = self._concealment_chance_vs(attacker, target)
if self._has_condition_named(attacker, "blinded"):
concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
if concealment >= _TOTAL_CONCEALMENT_PCT:
@@ -4900,6 +4969,9 @@ class CombatEngine:
"""
if attacker.moved_this_turn:
return
# SRD "Stealth": "It's impossible to use Stealth while attacking,
# running, or charging" - charging forfeits the hidden state outright.
self._break_stealth(attacker, "charging")
inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon)
speed_cells = _speed_ft(attacker) // _SQUARE_FT
max_cells = self._charge_speed_mult(attacker) * speed_cells
@@ -4985,7 +5057,7 @@ class CombatEngine:
return self._execute_delay(state, target, action)
if kind == "ready":
return self._execute_ready(state, action)
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone", "hide"):
return self._execute_self_action(state, kind)
if kind == "aid_another":
return self._execute_aid_another(state, target, action)
@@ -5090,6 +5162,8 @@ class CombatEngine:
return self._execute_fight_defensively(state)
if kind == "stand_up":
return self._execute_stand_up(state)
if kind == "hide":
return self._execute_hide(state)
return self._execute_drop_prone(state)
def _execute_total_defense(self, state: CombatantState) -> ActionResult:
@@ -5308,6 +5382,9 @@ class CombatEngine:
``missing_target``.
"""
kind = "run"
# SRD "Stealth": "It's impossible to use Stealth while attacking,
# running, or charging" - running forfeits the hidden state outright.
self._break_stealth(state, "running")
if state.moved_this_turn:
# Defense in depth, mirroring the _move/_5foot_step no-op-ok
# convention. Unreachable through the filtered path: run is a
@@ -5576,6 +5653,178 @@ class CombatEngine:
f"Power Attack activated"
)
def _stealth_size_mod(self, state: CombatantState) -> int:
"""The size bonus/penalty the state's Stealth checks carry.
PF1e (SRD "Stealth" - Modifiers, d20pfsrd Stealth (CRB): "Creatures
gain a bonus or penalty on Stealth checks based on their size: Fine
+16, Diminutive +12, Tiny +8, Small +4, Medium +0, Large -4, Huge -8,
Gargantuan -12, Colossal -16"). The data model bakes size into AC and
attack JSON values but not into skill ranks, so the table rides the
check here.
"""
return _STEALTH_SIZE_MOD.get(state.combatant.size.lower(), 0)
def _stealth_check(
self, state: CombatantState, *, penalty: int = 0
) -> SkillCheckResult:
check = self.skill_check(state, "stealth")
return SkillCheckResult(
roll=check.roll, total=check.total + self._stealth_size_mod(state) + penalty
)
def _notice_check(
self, observer: CombatantState, target: CombatantState
) -> SkillCheckResult:
"""One observer's Perception check to notice ``target``.
PF1e (SRD "Perception", KB rules/pathfinder-unchained/perception-wis.md
notice-modifier table: "Distance to the source, object, or creature:
+1 per 10 feet" DC modifier = -1 per 10 feet on the check). The
table's "distracted +5" modifier is NOT applied: the SRD grants it to
a specifically distracted creature and defines no automatic
in-combat = distracted rule, and the model has no distracted state
(documented out of scope).
"""
dist_ft = self.creature_distance(observer, target) * _SQUARE_FT
penalty = -(dist_ft // _STEALTH_DISTANCE_FT)
mods: tuple[StatModifier, ...] = ()
if penalty:
mods = (StatModifier(target="perception", value=penalty),)
return self.skill_check(observer, "perception", extra_mods=mods)
def _directly_observed(
self, hider: CombatantState, observer: CombatantState
) -> bool:
"""True when the observer directly sees the hider (Stealth impossible).
PF1e (SRD "Stealth" - Being Observed, KB rules/ultimate-intrigue/
perception-and-stealth.md: "characters can't use Stealth while being
observed... finding cover or concealment allows you to use Stealth"):
line of effect, no terrain cover, no concealment on the hider and a
sighted observer means the hider IS observed.
"""
if self._has_condition_named(observer, "blinded"):
return False
if not has_line_of_effect_cells(
self._grid, observer.occupied_cells(), hider.occupied_cells()
):
return False
if self._concealment_chance(hider) > 0:
return False
return (
cover_bonus_cells(
self._grid,
observer.occupied_cells(),
hider.occupied_cells(),
ranged=True,
occupied=frozenset(),
)
== 0
)
def _execute_hide(self, state: CombatantState) -> ActionResult:
"""Execute the hide action (PF1e, SRD "Stealth"): a move action.
One Stealth check (d20 + ranks + Dex + trained class skill + armor
check penalty + size) opposed by every enemy observer's Perception
check (distance -1 per 10 ft). Observers that fail to BEAT the
Stealth total cannot perceive the creature until it attacks or moves
(SRD "Stealth": "Creatures that fail to beat your Stealth check are
not aware of you and treat you as if you had total concealment";
opposed-check resolution per CRB "Skill Checks"). An observer that
directly sees the hider is never beaten - Stealth against it is
impossible (SRD "Being Observed").
"""
kind = "hide"
stealth: SkillCheckResult | None = None
hidden: set[str] = set()
for observer in self._states:
if (
observer is state
or not observer.active
or observer.side == state.side
):
continue
if self._directly_observed(state, observer):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"hide vs {observer.combatant.id} -> observed "
f"(Stealth impossible)"
)
continue
if stealth is None:
stealth = self._stealth_check(state)
perc = self._notice_check(observer, state)
if stealth.total > perc.total:
hidden.add(observer.combatant.id)
verdict = "hidden"
else:
verdict = "noticed"
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"hide stealth d20={stealth.roll}+{stealth.total - stealth.roll}"
f"={stealth.total} vs {observer.combatant.id} perception "
f"d20={perc.roll}+{perc.total - perc.roll}={perc.total} "
f"-> {verdict}"
)
state.hidden_from = hidden
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _stealth_after_move(self, state: CombatantState, *, penalty: int) -> None:
"""Re-run the opposed check when a hidden creature moves.
PF1e (SRD "Stealth" - Breaking Stealth, d20pfsrd Stealth (CRB): "you
can leave cover or concealment and remain unobserved as long as you
succeed at a Stealth check and end your turn in cover or
concealment"; "You can move up to half your normal speed and use
Stealth at no penalty. When moving at a speed greater than half...
you take a -5 penalty"). Engine simplification (documented): the
half-speed option is not separately expressible for a move action
(its budget is the full speed), so a move/withdraw re-check carries
the -5; a 5-foot step (always within half speed) carries 0. An
observer that ends the move directly observing the creature is
aware of it with no roll.
"""
if not state.hidden_from:
return
stealth: SkillCheckResult | None = None
for observer_id in sorted(state.hidden_from):
observer = next(
s for s in self._states if s.combatant.id == observer_id
)
if not observer.active:
continue
if self._directly_observed(state, observer):
state.hidden_from.discard(observer_id)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"no longer hidden from {observer_id} (observed)"
)
continue
if stealth is None:
stealth = self._stealth_check(state, penalty=penalty)
perc = self._notice_check(observer, state)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"hide stealth d20={stealth.roll}+{stealth.total - stealth.roll}"
f"={stealth.total} vs {observer_id} perception "
f"d20={perc.roll}+{perc.total - perc.roll}={perc.total} "
f"-> {'hidden' if stealth.total > perc.total else 'noticed'}"
)
if perc.total >= stealth.total:
state.hidden_from.discard(observer_id)
def _break_stealth(self, state: CombatantState, reason: str) -> None:
"""End a hidden state explicitly (SRD "Stealth": impossible while
running or charging - the action itself forfeits the check)."""
if state.hidden_from:
state.hidden_from.clear()
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"stealth broken ({reason})"
)
def _check_reactions(self, event: ReactionEvent) -> None:
"""Fire readied actions first, then offer the event to the reaction policy.
@@ -7521,6 +7770,7 @@ class CombatEngine:
self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
or self._is_hidden_from(caster, target)
)
if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
@@ -7563,6 +7813,7 @@ class CombatEngine:
self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
or self._is_hidden_from(caster, target)
)
if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
@@ -7588,7 +7839,11 @@ class CombatEngine:
"""Finish a touch-attack roll: hit check, crit threat/confirm (only
when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then
the concealment miss chance (a blinded caster faces total concealment,
same max(target, 50) convention as _resolve_hit_damage)."""
same max(target, 50) convention as _resolve_hit_damage). A hidden
caster delivers the touch as an unseen attacker (Dex denial in the
caller) and this attack roll ends its Stealth (SRD "Breaking
Stealth")."""
caster.hidden_from.clear()
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
self._log(
f"round {self._current_round} {caster.combatant.id}: {label} vs "
@@ -7608,7 +7863,7 @@ class CombatEngine:
f"crit confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} "
f"-> {'CRIT' if crit else 'no crit'}"
)
concealment = self._concealment_chance(target)
concealment = self._concealment_chance_vs(caster, target)
blinded = self._has_condition_named(caster, "blinded")
miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment
if miss_chance > 0:
@@ -9108,6 +9363,8 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
)
# A 5-foot step is always within half speed: Stealth re-check at 0.
self._stealth_after_move(state, penalty=0)
def _step5_to(self, state: CombatantState, destination: Pos) -> ActionResult:
"""5-foot step to an explicit adjacent cell — never provokes (U5c)."""
@@ -9145,6 +9402,8 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({destination[0]},{destination[1]})"
)
# A 5-foot step is always within half speed: Stealth re-check at 0.
self._stealth_after_move(state, penalty=0)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _move(
@@ -9166,6 +9425,7 @@ class CombatEngine:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
def _move_to(
self, state: CombatantState, destination: Pos, *, tumble: bool = False
@@ -9204,6 +9464,7 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: "
f"move -> ({destination[0]},{destination[1]})"
)
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _destination_reachable(
@@ -9658,6 +9919,8 @@ class CombatEngine:
self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
)
# Withdraw moves at double speed: the Stealth re-check carries -5.
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
def _log(self, line: str) -> None:
self._transcript.append(line)
@@ -10094,7 +10357,7 @@ _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
"delay", "ready", "total_defense", "aid_another",
})
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
"move", "stand_up", "retrieve_weapon", "repair_weapon",
"move", "stand_up", "retrieve_weapon", "repair_weapon", "hide",
})
_ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
{"swift", "deed", "inspire_courage"}