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:
+275
-12
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""PF1e Stealth + opposed Perception (SRD "Stealth" / "Perception").
|
||||||
|
|
||||||
|
Verified sources:
|
||||||
|
- KB rules/ultimate-intrigue/perception-and-stealth.md: "a creature using
|
||||||
|
Stealth (which calls for an opposed Perception check)"; CRB quoted there:
|
||||||
|
perceiving creatures that "fail to beat a sneaking character's Stealth check
|
||||||
|
result are not aware of the sneaking character"; "characters can't use
|
||||||
|
Stealth while being observed... finding cover or concealment allows you to
|
||||||
|
use Stealth".
|
||||||
|
- KB rules/pathfinder-unchained/perception-wis.md (notice table): "Notice a
|
||||||
|
creature using Stealth: Opposed by Stealth"; "Distance to the source,
|
||||||
|
object, or creature: +1 per 10 feet" (DC modifier = -1/10 ft on the
|
||||||
|
observer's check). The table's "distracted +5" is NOT applied: no SRD rule
|
||||||
|
makes in-combat observers automatically distracted and the model has no
|
||||||
|
distracted state (documented out of scope).
|
||||||
|
- KB rules/prpg-core-rulebook/skill-checks.md: opposed checks - "the attempt
|
||||||
|
is successful if your check result exceeds the result of the target"; no
|
||||||
|
natural 1/20 auto rules on skill checks.
|
||||||
|
- SRD Stealth (d20pfsrd, CRB text): hidden creatures are treated as having
|
||||||
|
TOTAL CONCEALMENT vs unaware observers; the unseen attacker gets +2 and
|
||||||
|
denies the defender its Dex bonus; "Your Stealth immediately ends after
|
||||||
|
you make an attack roll"; "impossible to use Stealth while attacking,
|
||||||
|
running, or charging"; size modifiers (Small +4 ... Large -4); moving
|
||||||
|
faster than half speed takes -5.
|
||||||
|
|
||||||
|
Engine: `Action(kind="hide")` (move action) runs the opposed check; the
|
||||||
|
per-observer hidden state lives in `CombatantState.hidden_from`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.effects import ActiveCondition
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
SkillRank,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
|
|
||||||
|
def _sword(cid: str, bonus: int = 3) -> AttackSpec:
|
||||||
|
return AttackSpec(
|
||||||
|
id=f"{cid}-sword",
|
||||||
|
name="short sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=bonus,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
size: str = "Medium",
|
||||||
|
dex: int = 10,
|
||||||
|
wis: int = 10,
|
||||||
|
stealth: SkillRank | None = None,
|
||||||
|
perception: SkillRank | None = None,
|
||||||
|
ac: int = 15,
|
||||||
|
touch: int = 10,
|
||||||
|
flat: int = 10,
|
||||||
|
) -> Combatant:
|
||||||
|
skills: dict[str, SkillRank] = {}
|
||||||
|
if stealth is not None:
|
||||||
|
skills["stealth"] = stealth
|
||||||
|
if perception is not None:
|
||||||
|
skills["perception"] = perception
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size=size,
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=dex, con_score=10,
|
||||||
|
int_score=10, wis_score=wis, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=30,
|
||||||
|
ac=ACProfile(total=ac, touch=touch, flat_footed=flat),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=[_sword(cid)],
|
||||||
|
skills=skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(
|
||||||
|
rng: list[int],
|
||||||
|
placements: list[tuple[Combatant, tuple[int, int], str]],
|
||||||
|
) -> CombatEngine:
|
||||||
|
states = [
|
||||||
|
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
|
||||||
|
for c, pos, side in placements
|
||||||
|
]
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||||
|
return CombatEngine(ScriptedRng(rng), Grid.from_spec(spec), states)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(eng: CombatEngine, cid: str) -> CombatantState:
|
||||||
|
return next(s for s in eng._states if s.combatant.id == cid)
|
||||||
|
|
||||||
|
|
||||||
|
def _blind(state: CombatantState) -> None:
|
||||||
|
state.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME["blinded"], rounds_left=5, source="test"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ROGUE_SKILL = SkillRank(rank=4, class_skill=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the opposed check ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_hide_opposed_check_hides_from_observer_that_fails_to_beat_it() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
|
||||||
|
goblin = _combatant("goblin", wis=8, perception=SkillRank(rank=1, class_skill=True))
|
||||||
|
# Blinded observer cannot see it: the opposed (hearing) check applies.
|
||||||
|
eng = _engine([10, 5], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
_blind(eng._states[1])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
result = eng._execute(s, Action(kind="hide"))
|
||||||
|
assert result.ok
|
||||||
|
assert result.reason == "ok"
|
||||||
|
# Stealth: 10 + 4 ranks + 4 Dex + 3 class = 21. Perception: 5 + 1 + 3
|
||||||
|
# class - 1 Wis - 1 (10 ft distance) = 7. 21 beats 7 -> hidden.
|
||||||
|
assert s.hidden_from == {"goblin"}
|
||||||
|
assert any(
|
||||||
|
"hide stealth d20=10+11=21 vs goblin perception d20=5+2=7 -> hidden"
|
||||||
|
in ln
|
||||||
|
for ln in eng._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hide_fails_when_observer_beats_stealth() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
|
||||||
|
goblin = _combatant("goblin", wis=14, perception=SkillRank(rank=5, class_skill=True))
|
||||||
|
eng = _engine([1, 20], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
_blind(eng._states[1])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="hide"))
|
||||||
|
# Stealth 1+11=12 vs Perception 20+5+3+2-1=29: noticed (no auto-20 on a
|
||||||
|
# skill check is irrelevant here - the total wins outright).
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
assert any("-> noticed" in ln for ln in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_auto_success_or_fail_on_the_skill_check() -> None:
|
||||||
|
# CRB "Skill Checks": the opposed attempt succeeds only if it EXCEEDS the
|
||||||
|
# target's result - a natural 20 on Stealth that ties the Perception
|
||||||
|
# total is not an auto-success, and a natural 1 with a big bonus is not
|
||||||
|
# an auto-fail.
|
||||||
|
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
|
||||||
|
goblin = _combatant("goblin", wis=18, perception=SkillRank(rank=5, class_skill=True))
|
||||||
|
eng = _engine([20, 20], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
_blind(eng._states[1])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="hide"))
|
||||||
|
# 20+11=31 vs 20+5+3+4-1=31: the attempt did not exceed -> noticed.
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_observed_creature_cannot_hide() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18, stealth=_ROGUE_SKILL)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
# Open arena, line of effect, no cover, sighted observer: the hider is
|
||||||
|
# directly observed -> Stealth is impossible against it (no roll needed).
|
||||||
|
eng = _engine([10], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="hide"))
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
assert any(
|
||||||
|
"hide vs goblin -> observed (Stealth impossible)" in ln
|
||||||
|
for ln in eng._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stealth_carries_the_size_modifier() -> None:
|
||||||
|
rogue = _combatant(
|
||||||
|
"rogue", size="Small", dex=18, stealth=_ROGUE_SKILL
|
||||||
|
)
|
||||||
|
goblin = _combatant("goblin", wis=8)
|
||||||
|
eng = _engine([10, 1], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
_blind(eng._states[1])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="hide"))
|
||||||
|
# Small: +4 size bonus on top of 10 + 4 + 4 + 3 = 21 -> 25.
|
||||||
|
assert any("hide stealth d20=10+15=25" in ln for ln in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the hidden creature is invisible to the failed observers ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_attacker_gets_unseen_bonus_and_denies_target_dex() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin", ac=20, touch=10, flat=10)
|
||||||
|
# Attack roll d20=10, then the 1d6 damage die.
|
||||||
|
eng = _engine([10, 3], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
weapon = rogue.attacks[0]
|
||||||
|
result = eng.resolve_attack(s, eng._states[1], weapon)
|
||||||
|
# Unseen attacker: +2 rides the roll; the target is denied its Dex bonus
|
||||||
|
# so the flat-footed AC 10 applies (not 20).
|
||||||
|
assert result.total == 10 + 3 + 2
|
||||||
|
assert result.ac == 10
|
||||||
|
assert result.hit
|
||||||
|
|
||||||
|
|
||||||
|
def test_attacking_breaks_stealth() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
eng = _engine([10, 1], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
eng.resolve_attack(s, eng._states[1], rogue.attacks[0])
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaware_attacker_faces_total_concealment_vs_hidden_creature() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18, ac=10, touch=10, flat=10)
|
||||||
|
goblin = _combatant("goblin", ac=10)
|
||||||
|
# Attack roll hits (d20=10+3 >= 10), then the d100 concealment roll: 30
|
||||||
|
# <= 50 -> the unaware attacker misses the hidden creature entirely.
|
||||||
|
eng = _engine([10, 30], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
result = eng.resolve_attack(eng._states[1], s, goblin.attacks[0])
|
||||||
|
assert result.hit
|
||||||
|
assert result.damage == 0
|
||||||
|
assert s.hp == s.combatant.hp_max
|
||||||
|
assert any("concealment vs rogue" in ln for ln in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaware_enemy_gets_no_aoop_against_a_hidden_mover() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
# AoO attack roll would consume a die if taken; none is available.
|
||||||
|
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 1), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="move", destination=(0, 3)))
|
||||||
|
assert any(
|
||||||
|
"AoO vs rogue suppressed (total concealment)" in ln
|
||||||
|
for ln in eng._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── stealth lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_while_hidden_rechecks_and_open_ground_reveals() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 4), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="move", destination=(0, 2)))
|
||||||
|
# The goblin directly observes the new position -> aware, no roll consumed.
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
assert any(
|
||||||
|
"no longer hidden from goblin (observed)" in ln for ln in eng._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_breaks_stealth_without_a_check() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
eng = _engine([], [(rogue, (0, 0), "players"), (goblin, (0, 5), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="run", destination=(0, 3)))
|
||||||
|
assert s.hidden_from == set()
|
||||||
|
assert any("stealth broken (running)" in ln for ln in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_breaks_stealth_without_a_check() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
eng = _engine([10, 2], [(rogue, (0, 0), "players"), (goblin, (0, 3), "monsters")])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
s.hidden_from = {"goblin"}
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="charge", target_id="goblin"))
|
||||||
|
assert any("stealth broken (charging)" in ln for ln in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
# ── observation contract ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_exposes_hidden_from() -> None:
|
||||||
|
rogue = _combatant("rogue", dex=18)
|
||||||
|
goblin = _combatant("goblin")
|
||||||
|
eng = _engine([10, 5], [(rogue, (0, 0), "players"), (goblin, (0, 2), "monsters")])
|
||||||
|
_blind(eng._states[1])
|
||||||
|
s = _state(eng, "rogue")
|
||||||
|
eng._current_round = 1
|
||||||
|
eng._execute(s, Action(kind="hide"))
|
||||||
|
obs = eng._build_observation(s)
|
||||||
|
me = next(c for c in obs.combatants if c["id"] == "rogue")
|
||||||
|
assert me["hidden_from"] == ("goblin",)
|
||||||
|
foe = next(c for c in obs.combatants if c["id"] == "goblin")
|
||||||
|
assert foe["hidden_from"] == ()
|
||||||
Reference in New Issue
Block a user