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:
+171
-30
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user