fix(rules): run is bounded by Constitution endurance and the Run feat - after a number of consecutive run rounds equal to the CON score each further round requires a Constitution check (DC 10 +1 per prior check, no auto nat-1/20, undead/constructs exempt with no CON score); failure stops the run and forces the SRD 1-minute (10-round) rest; the Run feat grants +4 on the check and keeps the Dex bonus to AC while running (SRD Full-Round Actions - Run); heavy-armor x3 speed clause documented out-of-scope (no armor field); +5 tests

This commit is contained in:
2026-09-19 22:02:36 +02:00
parent 28bc624d1e
commit ea835293a0
3 changed files with 363 additions and 34 deletions
+171 -30
View File
@@ -275,6 +275,15 @@ _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
# PF1e (SRD "Full-Round Actions — Run"): after running a number of rounds equal
# to your Constitution score, each further round of running requires a DC 10
# Constitution check whose DC rises by 1 per check made; the Run feat grants
# +4 on this check (and lets the runner keep its Dexterity bonus to AC). A
# failed check stops the run and forces a 1-minute (10-round) rest.
_RUN_FEAT = "Run"
_RUN_CHECK_BASE_DC = 10
_RUN_FEAT_CHECK_BONUS = 4
_RUN_REST_ROUNDS = 10
_OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin
_BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD
_DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD
@@ -791,6 +800,15 @@ class CombatantState:
aid_ac_bonus: int = 0
aid_grants: list[tuple[str, str]] = field(default_factory=list)
running: bool = False
# Run endurance (SRD "Full-Round Actions — Run", not in the frozen data
# model): run_rounds counts the CONSECUTIVE rounds this combatant has run;
# once it reaches the combatant's Constitution score, each further round
# requires a Constitution check (DC 10 + run_checks, +4 with the Run feat);
# a failure stops the run and sets run_rest_rounds (10-round rest, no
# running). Undead/constructs have no Constitution score: exempt.
run_rounds: int = 0
run_checks: int = 0
run_rest_rounds: int = 0
# Bleeding (V5b, not in the frozen data model): damage taken at the start
# of each turn; any effect that restores hit points clears it.
bleed_damage: int = 0
@@ -1045,7 +1063,9 @@ class Action:
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
reusing the move machinery (step-by-step, AoO-provoking); it sets
``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC.
runner its Dexterity bonus to AC (unless it has the Run feat). Running
beyond a number of consecutive rounds equal to the runner's Constitution
score requires a DC 10 Constitution check each round (Run feat: +4).
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
@@ -1514,6 +1534,8 @@ class CombatEngine:
"aid_attack_bonus": state.aid_attack_bonus,
"aid_ac_bonus": state.aid_ac_bonus,
"running": state.running,
"run_rounds": state.run_rounds,
"run_rest_rounds": state.run_rest_rounds,
"bleed_damage": state.bleed_damage,
"recurring_damage": [
{
@@ -1886,7 +1908,7 @@ class CombatEngine:
state.fight_defensively = False
state.total_defense = False
self._expire_aid_grants(state)
state.running = False
self._tick_run_endurance(state)
state.swift_used_this_turn = False
original = self._policy(self, state)
results: list[ActionResult] = []
@@ -2905,11 +2927,13 @@ class CombatEngine:
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
)
# Check if target denies DEX to AC (flat-footed); a running target
# loses its DEX bonus too (U5e), reusing the same AC path. An
# invisible attacker also denies the target's DEX bonus to AC.
# 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.
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(attacker, "invisible")
)
full_ac_base = (
@@ -4419,6 +4443,21 @@ class CombatEngine:
break
aider.aid_grants = []
def _tick_run_endurance(self, state: CombatantState) -> None:
"""Per-round run-endurance bookkeeping at the start of the turn.
PF1e (SRD "Full-Round Actions — Run"): the consecutive-run streak
breaks when a round passes without running; the 10-round rest forced by
a failed endurance check ticks once per round (the failing round itself
counts as the first). Then the running flag lapses with the turn.
"""
if not state.running:
state.run_rounds = 0
state.run_checks = 0
if state.run_rest_rounds > 0:
state.run_rest_rounds -= 1
state.running = False
def _execute_run(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult:
@@ -4440,11 +4479,23 @@ class CombatEngine:
ends adjacent to the target. Friendly squares are still crossed, never
ended on (SRD Movement, conformance L5). On completion the combatant is
``running`` until the start of its next turn, which denies it its
Dexterity bonus to AC. An explicit destination that the straight line
cannot honour within the 4x budget (blocked by terrain, difficult
terrain, a creature, or out of range) is rejected with
``unreachable_destination``; with neither target nor destination the
U1 gate rejects the action with ``missing_target``.
Dexterity bonus to AC unless it has the Run feat (SRD Run: "You lose
any Dexterity bonus to AC unless you have the Run feat"). PF1e bounds
the run by endurance: "You can run for a number of rounds equal to
your Constitution score, but after that you must make a DC 10
Constitution check to continue running. You must check again each
round in which you continue to run, and the DC of this check increases
by 1 for each check you have made. When you fail this check, you must
stop running. A character who has run to his limit must rest for 1
minute (10 rounds) before running again." The Run feat grants +4 on
the check. Out of scope (documented, not approximated): the heavy-
armor clause "or three times your speed if you're in heavy armor" —
the model carries no armor field (AC is a final sheet value). An
explicit destination that the straight line cannot honour within the
4x budget (blocked by terrain, difficult terrain, a creature, or out
of range) is rejected with ``unreachable_destination``; with neither
target nor destination the U1 gate rejects the action with
``missing_target``.
"""
kind = "run"
if state.moved_this_turn:
@@ -4466,30 +4517,120 @@ class CombatEngine:
):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
# SRD Run endurance: the past-the-limit Constitution check and the
# 10-round rest that follows a failure gate the run (the turn is
# forfeited either way, with the verdict logged by the gate).
if not self._run_endurance_gate(state):
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if action.destination is not None:
if action.destination == state.pos:
path: list[Pos] = [] # already there: a valid zero-length run
else:
path = self._run_line_path(state, action.destination)
if not path or path[-1] != action.destination:
path = [] # the straight line could not honour it
if not path and action.destination != state.pos:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
self._walk_steps(state, path)
state.moved_this_turn = True
elif target is not None:
return self._run_to_destination(state, action.destination)
if target is not None:
path = self._run_line_path(state, target.pos)
if self._walk_steps(state, path):
state.moved_this_turn = True
return self._finish_run(state)
def _run_to_destination(self, state: CombatantState, destination: Pos) -> ActionResult:
"""Run to an explicit destination (U5c): the straight line must honour it.
A destination equal to the current position is a valid zero-length run;
a straight line that cannot reach the destination within the 4x budget
is rejected with ``unreachable_destination`` (no silent short-stop).
"""
kind = "run"
if destination == state.pos:
path: list[Pos] = [] # already there: a valid zero-length run
else:
path = self._run_line_path(state, destination)
if not path or path[-1] != destination:
path = [] # the straight line could not honour it
if not path and destination != state.pos:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
self._walk_steps(state, path)
state.moved_this_turn = True
return self._finish_run(state)
def _finish_run(self, state: CombatantState) -> ActionResult:
"""Complete a run round: extend the streak and set the running flag."""
state.run_rounds += 1
state.running = True
self._log(f"round {self._current_round} {state.combatant.id}: run")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
return ActionResult(kind="run", ok=True, reason=_REASON_OK)
def _running_denies_dex(self, state: CombatantState) -> bool:
"""True when the running state denies the Dex bonus to AC.
PF1e (SRD "Full-Round Actions — Run"): "You lose any Dexterity bonus
to AC unless you have the Run feat."
"""
return state.running and not self._has_feat(state, _RUN_FEAT)
def _run_endurance_gate(self, state: CombatantState) -> bool:
"""Endurance gate for one more run round (SRD "Full-Round Actions — Run").
During the post-failure rest the character "can move no faster than a
normal move action", so the run is forfeited. Past a number of
consecutive rounds equal to the Constitution score, each further round
requires the escalating Constitution check (see
``_run_endurance_check``). Returns True when the run may proceed.
"""
if state.run_rest_rounds > 0:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"wait (cannot run, resting {state.run_rest_rounds} rounds)"
)
return False
return self._run_endurance_check(state)
def _run_endurance_check(self, state: CombatantState) -> bool:
"""Constitution check to keep running past the runner's CON score.
PF1e (SRD "Full-Round Actions — Run"): "You can run for a number of
rounds equal to your Constitution score, but after that you must make
a DC 10 Constitution check to continue running. You must check again
each round in which you continue to run, and the DC of this check
increases by 1 for each check you have made. When you fail this check,
you must stop running. A character who has run to his limit must rest
for 1 minute (10 rounds) before running again." The Run feat grants
"+4 bonus on this check" (SRD Feats — Run). Undead and constructs have
no Constitution score and cannot be fatigued, so they are exempt. An
ability check takes no automatic success/failure on a natural roll
(CRB "Using Ability Scores"). Returns True when the run may proceed.
"""
if state.combatant.creature_type != "living":
return True
if state.run_rounds < state.combatant.abilities.con_score:
return True
dc = _RUN_CHECK_BASE_DC + state.run_checks
con_mod = (state.combatant.abilities.con_score - 10) // 2
bonus = _RUN_FEAT_CHECK_BONUS if self._has_feat(state, _RUN_FEAT) else 0
roll = self._rng.d20()
total = roll + con_mod + bonus
state.run_checks += 1
check = f"d20({roll}) + CON({con_mod})"
if bonus:
check += f" + Run({bonus})"
check += f" = {total} vs DC {dc}"
if total >= dc:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"keeps running ({check})"
)
return True
state.run_rounds = 0
state.run_checks = 0
state.run_rest_rounds = _RUN_REST_ROUNDS
self._log(
f"round {self._current_round} {state.combatant.id}: run stopped "
f"({check} failed, rests {_RUN_REST_ROUNDS} rounds)"
)
return False
def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action
@@ -6402,7 +6543,7 @@ class CombatEngine:
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
)
if denies_dex:
@@ -6444,7 +6585,7 @@ class CombatEngine:
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._running_denies_dex(target)
or self._has_condition_named(caster, "invisible")
)
if denies_dex: