From 2c40994610de5cec229d2c9cb943c8ce12a3e8b7 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 21:39:54 +0200 Subject: [PATCH] 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 --- src/pf1e_simulator/combat.py | 287 +++++++++++++++++++- tests/test_conformance_action_economy.py | 3 +- tests/test_stealth.py | 324 +++++++++++++++++++++++ 3 files changed, 601 insertions(+), 13 deletions(-) create mode 100644 tests/test_stealth.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index de8a860..d59c495 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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"} diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index 23e6091..4258b27 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -72,6 +72,7 @@ _ALL_KINDS: tuple[_Kind, ...] = ( "inspire_courage", "retrieve_weapon", "repair_weapon", "activate_power_attack", "delay", "ready", "total_defense", "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", # move actions "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": "full_round", # swift actions diff --git a/tests/test_stealth.py b/tests/test_stealth.py new file mode 100644 index 0000000..b5913ab --- /dev/null +++ b/tests/test_stealth.py @@ -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"] == ()