fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)

This commit is contained in:
2026-09-10 21:45:50 +02:00
parent 5afe495c4a
commit 9aee4f6f6b
15 changed files with 934 additions and 134 deletions
+213 -53
View File
@@ -28,8 +28,13 @@ Phase 0 documented deviations from PF1e (conventions):
natural weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit.
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- DR applies once, after crit multiplication; a single bypass type defeats
DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
EVERY component (PF1e "DR X/Y and Z" notation).
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e
has no -10 rule. At exactly 0 hp a living creature is disabled: active,
staggered (one move OR standard action per round) at half speed, and a
non-movement action costs 1 hp at its end (hp -> -1, dying).
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: a move action travels up to the combatant's speed in cells along
the true shortest path (Dijkstra cost field from the target, 5-10-5
@@ -42,8 +47,11 @@ Phase 0 documented deviations from PF1e (conventions):
ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures.
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
threatens the target from the opposite border or corner. Ranged attacks do
- Flanking: +2 on melee attack rolls (melee touch included) when an active
ally THREATENS the target from the opposite border or corner — the ally
must actually threaten the target's squares with a usable melee weapon
(a reach weapon's 5-ft dead zone does not threaten) and must not be
unable to act (stunned/paralyzed allies do not flank). Ranged attacks do
not benefit from flanking; allies without melee weapons do not threaten.
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
active and wielding a melee weapon. Two triggers are modeled: (1) moving
@@ -130,7 +138,6 @@ from pf1e_simulator.los import (
)
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
@@ -174,6 +181,39 @@ _DEX_ZERO_MOD = -5
_HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset(
{"helpless", "paralyzed", "asleep", "unconscious"}
)
# PF1e (CRB, Disabled): at exactly 0 hp a creature takes only a single move
# or standard action (staggered, half speed); move-equivalent and free
# activities are not "strenuous" and cost no hp — everything else does.
_DISABLED_MOVE_KINDS: frozenset[str] = frozenset(
{"move", "5foot_step", "stand_up", "drop_prone", "wait"}
)
# PF1e (CRB, Combat Maneuver): attempting a combat maneuver provokes an
# attack of opportunity from the target unless the attacker holds the
# Improved feat for that maneuver (escape never provokes).
_IMPROVED_MANEUVER_FEATS: dict[str, str] = {
"trip": "Improved Trip",
"disarm": "Improved Disarm",
"bull_rush": "Improved Bull Rush",
"sunder": "Improved Sunder",
"grapple": "Improved Grapple",
"overrun": "Improved Overrun",
"dirty_trick": "Improved Dirty Trick",
"steal": "Improved Steal",
"reposition": "Improved Reposition",
"drag": "Improved Drag",
}
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
# Strength skills the conformance adjudication groups with the Dex skills.
_DEX_BASED_SKILLS: frozenset[str] = frozenset(
{
"acrobatics", "climb", "disable_device", "escape_artist", "fly",
"ride", "sleight_of_hand", "stealth", "swim",
}
)
# PF1e (CRB): undead and constructs are immune to precision damage (sneak
# attack). Ooze/plant immunity is not modeled (the model has no such types).
_PRECISION_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"})
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
@@ -481,15 +521,19 @@ class CombatantState:
@property
def active(self) -> bool:
return self.hp > 0
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
Death: a living creature at exactly 0 hp can still take a single move
or standard action), so it remains active. hp < 0 is dying/dead."""
return self.hp >= 0
@property
def dead(self) -> bool:
return self.hp < self.death_threshold
return self.hp <= self.death_threshold
@property
def death_threshold(self) -> int:
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule)."""
return -self.combatant.abilities.con_score
@dataclass(frozen=True)
@@ -1291,36 +1335,25 @@ class CombatEngine:
state.aid_ac_bonus = 0
state.running = False
original = self._policy(self, state)
actions = original
results: list[ActionResult] = []
if state.immediate_used:
kept = _without_swift_bonus(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
state.immediate_used = False
if self._has_condition_named(state, "staggered"):
kept = _staggered_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
elif self.has_condition_flag(state, "cant_attack"):
kept = _nauseated_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
actions = self._filter_turn_actions(state, original, results)
before = len(self._transcript)
was_delayed = state.delayed_this_round
disabled = self._is_disabled(state)
for action in actions:
if not state.active:
break
results.append(self._execute(state, action))
if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS:
# PF1e (CRB, Disabled): completing a standard (or otherwise
# strenuous) action costs 1 hp — the creature slips to -1 and
# is dying. Simplification: if the action itself healed the
# creature above 0 hp the cost is not applied.
state.hp -= 1
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
)
if state.delayed_this_round and not was_delayed:
# A fresh delay forfeits the rest of the turn.
break
@@ -1334,6 +1367,43 @@ class CombatEngine:
state.last_action_results = results
return self._winner_side() is not None
def _filter_turn_actions(
self,
state: CombatantState,
actions: tuple[Action, ...],
results: list[ActionResult],
) -> tuple[Action, ...]:
"""Downgrade the policy's action sequence to the legal action economy.
Swift consumed by an immediate action; PF1e (CRB, Disabled): a
creature at exactly 0 hp is staggered — one move OR one standard
action per round, no full-round actions; nauseated: single move only.
Dropped kinds are reported into ``results``.
"""
if state.immediate_used:
kept = _without_swift_bonus(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
state.immediate_used = False
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
kept = _staggered_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
for kind in _dropped_kinds(actions, kept)
)
return kept
if self.has_condition_flag(state, "cant_attack"):
kept = _nauseated_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
for kind in _dropped_kinds(actions, kept)
)
return kept
return actions
def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
"""Check if any active condition has the given behavioral flag set to True."""
for ac in state.active_conditions:
@@ -1349,6 +1419,11 @@ class CombatEngine:
or any(cond.name == name for cond in state.conditions)
)
def _is_disabled(self, state: CombatantState) -> bool:
"""PF1e (CRB, Effects of Death): a living creature at exactly 0 hp is
disabled — staggered, half speed, and a strenuous action costs 1 hp."""
return state.hp == 0
def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
@@ -1389,8 +1464,12 @@ class CombatEngine:
return False
def _get_speed_mult(self, state: CombatantState) -> float:
"""Get the minimum speed multiplier from all active conditions."""
speed_mult = 1.0
"""Get the minimum speed multiplier from all active conditions.
PF1e (CRB, Disabled): a disabled creature (exactly 0 hp) moves at
half speed.
"""
speed_mult = 0.5 if state.hp == 0 else 1.0
for ac in state.active_conditions:
if ac.condition.speed_mult != 1.0:
speed_mult = min(speed_mult, ac.condition.speed_mult)
@@ -1750,6 +1829,15 @@ class CombatEngine:
weapon.kind == "touch" and weapon.range_increment_ft is not None
)
def _is_melee_attack(self, weapon: AttackSpec) -> bool:
"""PF1e: a melee touch attack (kind "touch" with no range increment,
e.g. the lich's Paralyzing Touch) is a melee attack — it gains the
melee benefits (flanking, high ground, vs-helpless +4, sneak attack).
"""
return weapon.kind == "melee" or (
weapon.kind == "touch" and weapon.range_increment_ft is None
)
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
"""PF1e: -2 per full range increment beyond the first, zero within it."""
if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None:
@@ -1763,7 +1851,11 @@ class CombatEngine:
"""+2 if an active ally threatens the target from the opposite side (CRB flanking).
The opposite side is the mirror of every attacker cell across every
target cell; an ally occupying any mirrored cell flanks.
target cell; an ally occupying any mirrored cell flanks. PF1e (CRB):
the ally must actually THREATEN the target — a usable melee weapon
whose reach covers a target square (a reach weapon's 5-ft dead zone
does not), and no condition that stops it from acting (stunned,
paralyzed, ...).
"""
opposite = {
(2 * tr - ar, 2 * tc - ac)
@@ -1775,9 +1867,11 @@ class CombatEngine:
continue
if not ally.active or ally.side != attacker.side:
continue
if self.has_condition_flag(ally, "cant_act"):
continue
if not (ally.occupied_cells() & opposite):
continue
if any(w.kind == "melee" for w in ally.combatant.attacks):
if self.threatened_squares(ally) & target.occupied_cells():
return _FLANK_BONUS
return 0
@@ -1952,10 +2046,12 @@ class CombatEngine:
roll = self._rng.d20()
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
# PF1e: melee touch attacks are melee attacks — flanking and high
# ground apply to them (see _is_melee_attack).
flank = self._flanking_bonus(attacker, target) if self._is_melee_attack(weapon) else 0
high_ground = (
_HIGH_GROUND_BONUS
if weapon.kind == "melee"
if self._is_melee_attack(weapon)
and self._elevation(attacker) > self._elevation(target)
else 0
)
@@ -1979,10 +2075,10 @@ class CombatEngine:
# attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"):
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e (CRB): melee attacks against a helpless target gain +4; the
# helpless state includes paralyzed, asleep and unconscious (stunned
# is NOT helpless). Ranged attacks gain no bonus.
if weapon.kind == "melee" and self._is_helpless(target):
# PF1e (CRB): melee attacks (melee touch included) against a helpless
# target gain +4; the helpless state includes paralyzed, asleep and
# unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
if self._is_melee_attack(weapon) and self._is_helpless(target):
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
@@ -2100,7 +2196,11 @@ class CombatEngine:
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
# PF1e (CRB): the confirmation roll is an attack roll — a natural
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
crit = confirm == _NATURAL_TWENTY or (
confirm != _NATURAL_ONE and confirm_total >= ac
)
concealment = self._concealment_chance(target)
# PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%.
@@ -2166,14 +2266,19 @@ class CombatEngine:
) -> int:
"""Sneak attack d6 count for this swing (0 when the sneak does not apply).
PF1e: precision damage when the target is flanked (melee only) or
denied its Dex bonus to AC (melee, or ranged within 30 ft); dice =
(level + 1) // 2; concealment denies the sneak attack entirely.
PF1e: precision damage when the target is flanked (melee only, melee
touch included) or denied its Dex bonus to AC (melee, or ranged within
30 ft); dice = (level + 1) // 2; concealment denies the sneak attack
entirely. PF1e (CRB): undead and constructs are immune to precision
damage — ooze/plant immunity is not modeled (no such creature types
exist in the data model).
"""
if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE):
return 0
if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES:
return 0
dice = (attacker.combatant.level + 1) // 2
is_melee = weapon.kind == "melee"
is_melee = self._is_melee_attack(weapon)
if is_melee and self._flanking_bonus(attacker, target) > 0:
return dice
if denies_dex and (
@@ -2249,12 +2354,26 @@ class CombatEngine:
attacker.deed_up_close_and_deadly = False
return damage
def _dr_bypassed(self, dr: DamageReduction, types: frozenset[str]) -> bool:
"""PF1e (CRB): "DR X/Y and Z" requires EVERY listed component to bypass.
A single-component bypass behaves as before (that type defeats the
DR); a multi-component bypass is an "and" list — all components must
be present on the attack. Documented gap: the weapon model exposes no
"magic" property, so a bypass list containing "magic" (the lich's
DR 15/bludgeoning and magic) can never be satisfied by a mundane
weapon — correctly, a non-magical weapon must NOT bypass it.
"""
if not dr.bypass:
return False
return dr.bypass <= types
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
types = {t for component in weapon.damage for t in component.types}
if types & dr.bypass:
types = frozenset(t for comp in weapon.damage for t in comp.types)
if self._dr_bypassed(dr, types):
return damage
return max(0, damage - dr.amount)
@@ -2264,7 +2383,7 @@ class CombatEngine:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
if damage_types & dr.bypass:
if self._dr_bypassed(dr, damage_types):
return damage
return max(0, damage - dr.amount)
@@ -2375,8 +2494,14 @@ class CombatEngine:
ability_name = skill_spec.ability
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
# Effect/condition modifiers
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill))
# Effect/condition modifiers. PF1e: modifiers targeting "dex_skills"
# (grappled/pinned/blinded -4) apply to the Dexterity-based skills
# listed in _DEX_BASED_SKILLS — they are stored under that aggregate
# target, not the concrete skill name, so collect them explicitly.
mods = self._stat_modifiers(state, skill)
if skill in _DEX_BASED_SKILLS:
mods.extend(self._stat_modifiers(state, "dex_skills"))
mod_bonus = resolve_modifiers(mods)
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
def _roll_initiative(self) -> list[CombatantState]:
@@ -2807,8 +2932,11 @@ class CombatEngine:
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
as for any move action.
as for any move action. Like every other movement it is mutually exclusive
with movement already spent this turn (``moved_this_turn``).
"""
if attacker.moved_this_turn:
return
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
speed_cells = _speed_ft(attacker) // _SQUARE_FT
@@ -3441,13 +3569,26 @@ class CombatEngine:
):
return
# PF1e (CRB, Combat Maneuver): attempting the maneuver provokes an
# attack of opportunity from the target — resolved BEFORE the CMB
# check — unless the attacker holds the Improved feat for it. Being
# knocked below 0 hp by that AoO forfeits the maneuver.
if not self._has_feat(state, _IMPROVED_MANEUVER_FEATS[maneuver]):
self._maneuver_provoked_aoo(state, target)
if not state.active:
return
attacker_cmb = self._maneuver_cmb(state)
target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20()
total = roll + attacker_cmb
m = _ManeuverRoll(
roll=roll, attacker_cmb=attacker_cmb, total=total,
target_cmd=target_cmd, success=roll != 1 and total >= target_cmd,
target_cmd=target_cmd,
# PF1e: the CMB check is an attack roll — natural 20 always
# succeeds, natural 1 always fails.
success=roll == _NATURAL_TWENTY
or (roll != _NATURAL_ONE and total >= target_cmd),
)
handlers: dict[str, Callable[[], None]] = {
@@ -3466,6 +3607,21 @@ class CombatEngine:
}
handlers[maneuver]()
def _maneuver_provoked_aoo(
self, state: CombatantState, target: CombatantState
) -> None:
"""Resolve the single AoO a combat maneuver provokes from its target.
The target strikes only if it threatens the attacker, is able to
make AoOs (``_resolve_aoo`` honours ``cant_aoo``) and has per-round
AoO allowance left — same budget as movement/ranged provocation.
"""
if not self.threatened_squares(target) & state.occupied_cells():
return
if self._aoo_used.get(target.combatant.id, 0) >= self._aoo_allowance(target):
return
self._resolve_aoo(target, state)
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``).
@@ -5207,7 +5363,11 @@ class CombatEngine:
The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move).
Like every other movement it is mutually exclusive with movement
already spent this turn (``moved_this_turn``).
"""
if state.moved_this_turn:
return
if threat is None:
return
path = self._flee_path(state, threat)
+5 -2
View File
@@ -143,12 +143,15 @@ DAZZLED = Condition(
),
)
# Blinded: -2 AC, loses Dex, -4 STR/DEX skill checks (CRB)
# Blinded: -2 AC, loses Dex, -4 Dexterity skill checks; NO flat attack
# penalty (the -2 was a non-RAW double penalty on top of the engine's
# modeled 50% total-concealment miss). The Str-skill half of the -4 is not
# modeled (no str_skills target). (CRB)
BLINDED = Condition(
name="blinded",
modifiers=(
StatModifier(target="ac", value=-2),
StatModifier(target="attack", value=-2),
StatModifier(target="dex_skills", value=-4),
),
denies_dex_to_ac=True,
)