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