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
+162 -2
View File
@@ -9,7 +9,11 @@ next attack targeting it (``aid_mode`` "ac"). ``run`` is a full-round action
moving up to 4x speed toward ``target_id`` or an explicit ``destination``,
reusing the move machinery (Dijkstra descent, step-by-step AoO walk); it
sets ``running``, which lasts until the runner's next turn and denies the
runner its Dexterity bonus to AC (the flat-footed path). Both kinds are
runner its Dexterity bonus to AC (the flat-footed path) unless it has the Run
feat. Running is bounded by endurance (SRD "Full-Round Actions — Run"): a
number of consecutive rounds equal to the runner's Constitution score, then a
DC 10 Constitution check each round (+1 DC per prior check, +4 with the Run
feat); a failure stops the run and forces a 10-round rest. Both kinds are
exposed through the U1 feedback contract (``missing_target`` when neither a
target nor a destination is given) and the three runtime fields are additive
snapshot keys. The transcript of a combat that uses neither is unchanged.
@@ -18,6 +22,7 @@ from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import (
Action,
ActionResult,
@@ -46,9 +51,12 @@ def _combatant(
ac: int = 10,
flat_ac: int | None = None,
dex: int = 10,
con: int = 10,
speed: int = 30,
attack_bonus: int = 5,
ranged: bool = False,
run_feat: bool = False,
creature_type: str = "living",
) -> Combatant:
weapon = AttackSpec(
id=f"{id_}-{'bow' if ranged else 'sword'}",
@@ -64,7 +72,7 @@ def _combatant(
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
str_score=10, dex_score=dex, con_score=con,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=flat_ac if flat_ac is not None else ac),
@@ -74,6 +82,12 @@ def _combatant(
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[weapon],
creature_type=creature_type,
features=(
[AbilitySpec(name="Run", category="feat", source_detail="test")]
if run_feat
else []
),
)
@@ -740,3 +754,149 @@ def test_no_aid_or_run_transcript_is_pinned() -> None:
assert state.aid_attack_bonus == 0
assert state.aid_ac_bonus == 0
assert state.running is False
# ── (k) run endurance: CON-based rounds, escalating DC 10 checks, Run feat ───
def _run_east_each_round(
calls: dict[str, int],
) -> Callable[[CombatEngine, CombatantState], tuple[Action, ...]]:
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
return (Action(kind="run", destination=(n + 1, 0)),)
return _WAIT
return policy
def test_run_past_constitution_rounds_requires_escalating_checks() -> None:
"""Given: a CON 2 hero (2 free run rounds) running every round
When: rounds 3 and 4 each demand the past-the-limit Constitution check
Then: round 3 passes DC 10 (d20 14 - 4 = 10), round 4 fails the escalated
DC 11 ("the DC of this check increases by 1 for each check you have
made"), the run stops with no movement, and the 10-round rest forbids
running while it lasts (SRD Full-Round Actions — Run)."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2, con=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (15, 15))
# Rolls: 2 initiatives (10, 5); round-3 check d20=14; round-4 check d20=14.
# Round 5 consumes nothing (rest rejection) — the queue pins RNG use.
eng = _duo(
_run_east_each_round(calls), [10, 5, 14, 14], round_cap=5,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
t = result.transcript
hero_state = eng._states[0]
assert "round 3 hero: keeps running (d20(14) + CON(-4) = 10 vs DC 10)" in t
assert (
"round 4 hero: run stopped (d20(14) + CON(-4) = 10 vs DC 11 failed, "
"rests 10 rounds)" in t
)
assert "round 5 hero: wait (cannot run, resting 9 rounds)" in t
# Rounds 1-3 moved one cell each; rounds 4-5 did not move.
assert hero_state.pos == (3, 0)
assert hero_state.running is False
assert hero_state.run_rest_rounds == 9
def test_run_streak_breaks_when_a_round_passes_without_running() -> None:
"""Given: a CON 2 hero that runs rounds 1-2 then waits round 3
When: it runs again in round 4
Then: no Constitution check is consumed (the consecutive-run streak reset
with the idle round) — the two initiative dice are the whole queue, so any
extra roll raises RngExhaustedError."""
calls: dict[str, int] = {}
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
if n == 2:
return _WAIT
return (Action(kind="run", destination=(n + 1, 0)),)
return _WAIT
grid = _open_grid(16)
hero = make_state(_combatant("hero", initiative_mod=2, con=2), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(policy, [10, 5], round_cap=4, hero=hero, foe=foe, grid=grid)
result = eng.run()
assert not any("vs DC" in line for line in result.transcript)
assert eng._states[0].pos == (4, 0) # rounds 1, 2 and 4 moved (round 4 to (4,0))
def test_undead_are_exempt_from_run_endurance_checks() -> None:
"""Given: an undead CON 2 combatant (no Constitution score, SRD Undead)
When: it runs for 5 consecutive rounds
Then: no Constitution check is ever consumed — the queue holds only the
two initiative dice."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(
_combatant("hero", initiative_mod=2, con=2, creature_type="undead"),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(
_run_east_each_round(calls), [10, 5], round_cap=5,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
assert not any("vs DC" in line for line in result.transcript)
assert eng._states[0].pos == (5, 0)
def test_run_feat_grants_four_on_the_endurance_check() -> None:
"""Given: a CON 2 hero WITH the Run feat running to round 3
When: the past-the-limit Constitution check is made at DC 10
Then: the feat's +4 turns a plain failure (10 - 4 = 6) into a success
(SRD Feats — Run: "+4 bonus on this check")."""
calls: dict[str, int] = {}
grid = _open_grid(16)
hero = make_state(
_combatant("hero", initiative_mod=2, con=2, run_feat=True), "players", (0, 0)
)
foe = make_state(_combatant("foe"), "monsters", (15, 15))
eng = _duo(
_run_east_each_round(calls), [10, 5, 10], round_cap=3,
hero=hero, foe=foe, grid=grid,
)
result = eng.run()
assert (
"round 3 hero: keeps running (d20(10) + CON(-4) + Run(4) = 10 vs DC 10)"
in result.transcript
)
assert eng._states[0].pos == (3, 0)
def test_run_feat_keeps_dex_bonus_to_ac_while_running() -> None:
"""Given: a Dex-18 (AC 14, flat-footed 10) hero WITH the Run feat
When: it runs and the foe attacks it the same round
Then: the attack resolves against AC 14 — the Run feat keeps the
Dexterity bonus (SRD Run: "You lose any Dexterity bonus to AC unless you
have the Run feat"); the feat-less control test above hits AC 10."""
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="run", destination=(0, 1)),)
return (Action(kind="attack", target_id="hero"),)
hero = make_state(
_combatant(
"hero", initiative_mod=2, ac=14, flat_ac=10, dex=18, run_feat=True
),
"players", (0, 0),
)
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _duo(policy, [10, 10, 5], round_cap=1, hero=hero, foe=foe)
t = eng.run().transcript
assert "round 1 hero: run" in t
assert "round 1 foe: short sword vs hero d20=5+5=10 AC 14 -> MISS" in t
assert eng._states[0].hp == 100