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:
@@ -951,7 +951,31 @@ Règles modélisées :
|
||||
terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est
|
||||
auto-dirigée**. À l'achèvement, le combattant est `running` jusqu'au début
|
||||
de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu
|
||||
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une
|
||||
par le même chemin flat-footed/`denies_dex_to_ac` que les conditions),
|
||||
**sauf s'il a le don Run** (SRD « Run » : « You lose any Dexterity bonus to
|
||||
AC unless you have the Run feat » — `_running_denies_dex`). **Endurance
|
||||
(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. » Le moteur compte les rounds de course **consécutifs** par
|
||||
combattant (`run_rounds`, la série se brise quand un tour passe sans
|
||||
courir) ; au-delà du score de Constitution, chaque nouvelle round exige un
|
||||
jet de Constitution DD `10 + jets déjà faits` (`run_checks`), **sans**
|
||||
réussite/échec automatique sur 20/1 naturel (un test de caractéristique
|
||||
n'est pas un jet d'attaque) ; le don **Run** donne **+4** à ce jet. Un échec
|
||||
stoppe la course (tour perdu, aucun mouvement, journalisé `run stopped`) et
|
||||
pose `run_rest_rounds = 10` : pendant le repos, toute action `run` est
|
||||
refusée (journalisé `wait (cannot run, resting …)`) — le round de l'échec
|
||||
compte comme le premier des dix. Les morts-vivants et les constructeurs n'ont
|
||||
pas de score de Constitution et sont exemptés. Simplification assumée : la
|
||||
règle du repos borne la course consécutive par un compteur de tours au sens
|
||||
du moteur (le « repos » n'exige pas l'inactivité totale, seule la course est
|
||||
interdite). **Hors périmètre (documenté, non approximé)** : la clause
|
||||
d'armure lourde « or three times your speed if you're in heavy armor » — le
|
||||
modèle n'a aucun champ armure (la CA des JSON est une valeur finale). Une
|
||||
`destination` que la ligne droite ne peut pas honorer dans le budget 4×
|
||||
(hors portée, bloquée par un obstacle/une créature, ou joignable uniquement
|
||||
en tournant un coin ou en traversant le terrain difficile) est **rejetée**
|
||||
@@ -1660,7 +1684,11 @@ Règles modélisées :
|
||||
début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide
|
||||
autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de
|
||||
son tour), running (bool — course en cours jusqu'au prochain tour du
|
||||
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
|
||||
combattant : il perd son bonus de Dextérité à la CA, sauf don Run),
|
||||
run_rounds (int — rounds de course consécutifs déjà courus, série brisée
|
||||
quand un tour passe sans courir) et run_rest_rounds (int — rounds de repos
|
||||
restants après un échec du jet d'endurance, course interdite tant que > 0)
|
||||
— voir « Course », bleed_damage (int —
|
||||
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
|
||||
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
|
||||
recurring_damage (S2c — liste des sources de dégâts récurrents de sort
|
||||
|
||||
+162
-21
@@ -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,14 +4517,34 @@ 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:
|
||||
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, action.destination)
|
||||
if not path or path[-1] != action.destination:
|
||||
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 action.destination != state.pos:
|
||||
if not path and destination != state.pos:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
||||
@@ -4483,13 +4554,83 @@ class CombatEngine:
|
||||
)
|
||||
self._walk_steps(state, path)
|
||||
state.moved_this_turn = True
|
||||
elif 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 _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:
|
||||
|
||||
+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