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 _TUMBLE_EXTRA_OPPONENT_DC = 2
# SRD "Acrobatics" - Tumble: "You move at half speed when you tumble". # SRD "Acrobatics" - Tumble: "You move at half speed when you tumble".
_TUMBLE_SPEED_FACTOR = 0.5 _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 _HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious — # PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5 # 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", "wait",
"retrieve_weapon", "retrieve_weapon",
"repair_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 # 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 # combatant while the target lives and stands in reach, dissipated when
# this combatant casts another spell. None when no charge is held. # this combatant casts another spell. None when no charge is held.
held_charge: HeldCharge | None = None 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]: def usable_attacks(self) -> list[AttackSpec]:
# PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken # PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken
@@ -1359,7 +1383,10 @@ class Action:
Combat Casting) or the spell fizzles. ``move`` accepts ``tumble``: the Combat Casting) or the spell fizzles. ``move`` accepts ``tumble``: the
move happens at half speed and each opponent crossed is passed with one move happens at half speed and each opponent crossed is passed with one
Acrobatics check against its CMD (SRD "Acrobatics" - Tumble; see the 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[ kind: Literal[
@@ -1369,7 +1396,7 @@ class Action:
"inspire_courage", "retrieve_weapon", "repair_weapon", "inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "ready", "activate_power_attack", "delay", "ready",
"total_defense", "fight_defensively", "stand_up", "drop_prone", "total_defense", "fight_defensively", "stand_up", "drop_prone",
"aid_another", "run", "aid_another", "run", "hide",
] ]
target_id: str | None = None target_id: str | None = None
spell_name: str | None = None spell_name: str | None = None
@@ -1852,6 +1879,10 @@ class CombatEngine:
"weapon_dropped": tuple(sorted(state.weapon_dropped)), "weapon_dropped": tuple(sorted(state.weapon_dropped)),
"carried_items": tuple(state.carried_items), "carried_items": tuple(state.carried_items),
"grappled_with": state.grappled_with, "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, "moved_this_turn": state.moved_this_turn,
"power_attack": state.power_attack, "power_attack": state.power_attack,
"defensive_ac_bonus": state.defensive_ac_bonus, "defensive_ac_bonus": state.defensive_ac_bonus,
@@ -3407,8 +3438,16 @@ class CombatEngine:
if self._has_condition_named(attacker, "prone"): if self._has_condition_named(attacker, "prone"):
atk_mods.append(StatModifier(target="attack", value=-4)) atk_mods.append(StatModifier(target="attack", value=-4))
# PF1e: an invisible attacker strikes from an unknown direction (+2 on # PF1e: an invisible attacker strikes from an unknown direction (+2 on
# attack and crit confirmation, target loses its Dex bonus to AC). # attack and crit confirmation, target loses its Dex bonus to AC). A
if self._has_condition_named(attacker, "invisible"): # 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)) atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e (CRB): melee attacks (melee touch included) against a helpless # PF1e (CRB): melee attacks (melee touch included) against a helpless
# target gain +4; the helpless state includes paralyzed, asleep and # 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 # 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 # has the Run feat (SRD Run: "You lose any Dexterity bonus to AC
# unless you have the Run feat"). An invisible attacker also denies # 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 = ( denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac") self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target) 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) ac = self._attack_ac(attacker, target, weapon, dist_ft, denies_dex=denies_dex)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
@@ -3495,6 +3535,13 @@ class CombatEngine:
else: else:
splash_total = self._splash_scatter(attacker, target, weapon) splash_total = self._splash_scatter(attacker, target, weapon)
attacker.aid_attack_bonus = 0 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( return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
@@ -3603,7 +3650,7 @@ class CombatEngine:
crit = confirm == _NATURAL_TWENTY or ( crit = confirm == _NATURAL_TWENTY or (
confirm != _NATURAL_ONE and confirm_total >= ac 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 # PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%. # concealment against it — the blinded attacker itself misses 50%.
attacker_blinded = self._has_condition_named(attacker, "blinded") attacker_blinded = self._has_condition_named(attacker, "blinded")
@@ -3819,6 +3866,28 @@ class CombatEngine:
chances += [cond.miss_chance for cond in target.conditions] chances += [cond.miss_chance for cond in target.conditions]
return max((c for c in chances if c > 0), default=0) 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( def _sneak_attack_dice(
self, self,
attacker: CombatantState, attacker: CombatantState,
@@ -4398,7 +4467,7 @@ class CombatEngine:
suppressed too. Melee line: terrain cover only — creatures grant no suppressed too. Melee line: terrain cover only — creatures grant no
melee cover. melee cover.
""" """
concealment = self._concealment_chance(target) concealment = self._concealment_chance_vs(attacker, target)
if self._has_condition_named(attacker, "blinded"): if self._has_condition_named(attacker, "blinded"):
concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
if concealment >= _TOTAL_CONCEALMENT_PCT: if concealment >= _TOTAL_CONCEALMENT_PCT:
@@ -4900,6 +4969,9 @@ class CombatEngine:
""" """
if attacker.moved_this_turn: if attacker.moved_this_turn:
return 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) inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon)
speed_cells = _speed_ft(attacker) // _SQUARE_FT speed_cells = _speed_ft(attacker) // _SQUARE_FT
max_cells = self._charge_speed_mult(attacker) * speed_cells max_cells = self._charge_speed_mult(attacker) * speed_cells
@@ -4985,7 +5057,7 @@ class CombatEngine:
return self._execute_delay(state, target, action) return self._execute_delay(state, target, action)
if kind == "ready": if kind == "ready":
return self._execute_ready(state, action) 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) return self._execute_self_action(state, kind)
if kind == "aid_another": if kind == "aid_another":
return self._execute_aid_another(state, target, action) return self._execute_aid_another(state, target, action)
@@ -5090,6 +5162,8 @@ class CombatEngine:
return self._execute_fight_defensively(state) return self._execute_fight_defensively(state)
if kind == "stand_up": if kind == "stand_up":
return self._execute_stand_up(state) return self._execute_stand_up(state)
if kind == "hide":
return self._execute_hide(state)
return self._execute_drop_prone(state) return self._execute_drop_prone(state)
def _execute_total_defense(self, state: CombatantState) -> ActionResult: def _execute_total_defense(self, state: CombatantState) -> ActionResult:
@@ -5308,6 +5382,9 @@ class CombatEngine:
``missing_target``. ``missing_target``.
""" """
kind = "run" 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: if state.moved_this_turn:
# Defense in depth, mirroring the _move/_5foot_step no-op-ok # Defense in depth, mirroring the _move/_5foot_step no-op-ok
# convention. Unreachable through the filtered path: run is a # convention. Unreachable through the filtered path: run is a
@@ -5576,6 +5653,178 @@ class CombatEngine:
f"Power Attack activated" 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: def _check_reactions(self, event: ReactionEvent) -> None:
"""Fire readied actions first, then offer the event to the reaction policy. """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") self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target) or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible") or self._has_condition_named(caster, "invisible")
or self._is_hidden_from(caster, target)
) )
if denies_dex: if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) 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") self.has_condition_flag(target, "denies_dex_to_ac")
or self._running_denies_dex(target) or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible") or self._has_condition_named(caster, "invisible")
or self._is_hidden_from(caster, target)
) )
if denies_dex: if denies_dex:
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) 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 """Finish a touch-attack roll: hit check, crit threat/confirm (only
when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then
the concealment miss chance (a blinded caster faces total concealment, 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) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
self._log( self._log(
f"round {self._current_round} {caster.combatant.id}: {label} vs " 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 confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} "
f"-> {'CRIT' if crit else 'no crit'}" 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") blinded = self._has_condition_named(caster, "blinded")
miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment
if miss_chance > 0: if miss_chance > 0:
@@ -9108,6 +9363,8 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})" 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: def _step5_to(self, state: CombatantState, destination: Pos) -> ActionResult:
"""5-foot step to an explicit adjacent cell — never provokes (U5c).""" """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"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({destination[0]},{destination[1]})" 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) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _move( def _move(
@@ -9166,6 +9425,7 @@ class CombatEngine:
state.moved_this_turn = True state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) 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._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
def _move_to( def _move_to(
self, state: CombatantState, destination: Pos, *, tumble: bool = False self, state: CombatantState, destination: Pos, *, tumble: bool = False
@@ -9204,6 +9464,7 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"move -> ({destination[0]},{destination[1]})" f"move -> ({destination[0]},{destination[1]})"
) )
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _destination_reachable( def _destination_reachable(
@@ -9658,6 +9919,8 @@ class CombatEngine:
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}" 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: def _log(self, line: str) -> None:
self._transcript.append(line) self._transcript.append(line)
@@ -10094,7 +10357,7 @@ _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
"delay", "ready", "total_defense", "aid_another", "delay", "ready", "total_defense", "aid_another",
}) })
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({ _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( _ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
{"swift", "deed", "inspire_courage"} {"swift", "deed", "inspire_courage"}
+2 -1
View File
@@ -72,6 +72,7 @@ _ALL_KINDS: tuple[_Kind, ...] = (
"inspire_courage", "retrieve_weapon", "repair_weapon", "inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "ready", "total_defense", "activate_power_attack", "delay", "ready", "total_defense",
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run", "fight_defensively", "stand_up", "drop_prone", "aid_another", "run",
"hide",
) )
@@ -99,7 +100,7 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None:
"aid_another": "standard", "aid_another": "standard",
# move actions # move actions
"move": "move", "stand_up": "move", "retrieve_weapon": "move", "move": "move", "stand_up": "move", "retrieve_weapon": "move",
"repair_weapon": "move", "repair_weapon": "move", "hide": "move",
# run: PF1e full-round action (4x speed); consumes standard + move. # run: PF1e full-round action (4x speed); consumes standard + move.
"run": "full_round", "run": "full_round",
# swift actions # swift actions
+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"] == ()