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
|
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
|
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
|
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×
|
`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
|
(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**
|
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
|
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
|
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
|
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
|
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
|
||||||
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
|
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`),
|
||||||
recurring_damage (S2c — liste des sources de dégâts récurrents de sort
|
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_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)
|
_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
|
_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
|
_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
|
_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
|
_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_ac_bonus: int = 0
|
||||||
aid_grants: list[tuple[str, str]] = field(default_factory=list)
|
aid_grants: list[tuple[str, str]] = field(default_factory=list)
|
||||||
running: bool = False
|
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
|
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
||||||
# of each turn; any effect that restores hit points clears it.
|
# of each turn; any effect that restores hit points clears it.
|
||||||
bleed_damage: int = 0
|
bleed_damage: int = 0
|
||||||
@@ -1045,7 +1063,9 @@ class Action:
|
|||||||
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
|
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
|
||||||
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
||||||
``running``, which lasts until the runner's next turn and denies the
|
``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
|
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
|
||||||
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
|
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
|
||||||
listed here AND known by the caster (in ``Combatant.metamagic_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_attack_bonus": state.aid_attack_bonus,
|
||||||
"aid_ac_bonus": state.aid_ac_bonus,
|
"aid_ac_bonus": state.aid_ac_bonus,
|
||||||
"running": state.running,
|
"running": state.running,
|
||||||
|
"run_rounds": state.run_rounds,
|
||||||
|
"run_rest_rounds": state.run_rest_rounds,
|
||||||
"bleed_damage": state.bleed_damage,
|
"bleed_damage": state.bleed_damage,
|
||||||
"recurring_damage": [
|
"recurring_damage": [
|
||||||
{
|
{
|
||||||
@@ -1886,7 +1908,7 @@ class CombatEngine:
|
|||||||
state.fight_defensively = False
|
state.fight_defensively = False
|
||||||
state.total_defense = False
|
state.total_defense = False
|
||||||
self._expire_aid_grants(state)
|
self._expire_aid_grants(state)
|
||||||
state.running = False
|
self._tick_run_endurance(state)
|
||||||
state.swift_used_this_turn = False
|
state.swift_used_this_turn = False
|
||||||
original = self._policy(self, state)
|
original = self._policy(self, state)
|
||||||
results: list[ActionResult] = []
|
results: list[ActionResult] = []
|
||||||
@@ -2905,11 +2927,13 @@ class CombatEngine:
|
|||||||
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
||||||
)
|
)
|
||||||
# Check if target denies DEX to AC (flat-footed); a running target
|
# Check if target denies DEX to AC (flat-footed); a running target
|
||||||
# loses its DEX bonus too (U5e), reusing the same AC path. An
|
# loses its DEX bonus too (U5e), reusing the same AC path — unless it
|
||||||
# invisible attacker also denies the target's DEX bonus to AC.
|
# 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 = (
|
denies_dex = (
|
||||||
self.has_condition_flag(target, "denies_dex_to_ac")
|
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")
|
or self._has_condition_named(attacker, "invisible")
|
||||||
)
|
)
|
||||||
full_ac_base = (
|
full_ac_base = (
|
||||||
@@ -4419,6 +4443,21 @@ class CombatEngine:
|
|||||||
break
|
break
|
||||||
aider.aid_grants = []
|
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(
|
def _execute_run(
|
||||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
@@ -4440,11 +4479,23 @@ class CombatEngine:
|
|||||||
ends adjacent to the target. Friendly squares are still crossed, never
|
ends adjacent to the target. Friendly squares are still crossed, never
|
||||||
ended on (SRD Movement, conformance L5). On completion the combatant is
|
ended on (SRD Movement, conformance L5). On completion the combatant is
|
||||||
``running`` until the start of its next turn, which denies it its
|
``running`` until the start of its next turn, which denies it its
|
||||||
Dexterity bonus to AC. An explicit destination that the straight line
|
Dexterity bonus to AC unless it has the Run feat (SRD Run: "You lose
|
||||||
cannot honour within the 4x budget (blocked by terrain, difficult
|
any Dexterity bonus to AC unless you have the Run feat"). PF1e bounds
|
||||||
terrain, a creature, or out of range) is rejected with
|
the run by endurance: "You can run for a number of rounds equal to
|
||||||
``unreachable_destination``; with neither target nor destination the
|
your Constitution score, but after that you must make a DC 10
|
||||||
U1 gate rejects the action with ``missing_target``.
|
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"
|
kind = "run"
|
||||||
if state.moved_this_turn:
|
if state.moved_this_turn:
|
||||||
@@ -4466,14 +4517,34 @@ class CombatEngine:
|
|||||||
):
|
):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
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 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
|
path: list[Pos] = [] # already there: a valid zero-length run
|
||||||
else:
|
else:
|
||||||
path = self._run_line_path(state, action.destination)
|
path = self._run_line_path(state, destination)
|
||||||
if not path or path[-1] != action.destination:
|
if not path or path[-1] != destination:
|
||||||
path = [] # the straight line could not honour it
|
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(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
||||||
@@ -4483,13 +4554,83 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
self._walk_steps(state, path)
|
self._walk_steps(state, path)
|
||||||
state.moved_this_turn = True
|
state.moved_this_turn = True
|
||||||
elif target is not None:
|
return self._finish_run(state)
|
||||||
path = self._run_line_path(state, target.pos)
|
|
||||||
if self._walk_steps(state, path):
|
def _finish_run(self, state: CombatantState) -> ActionResult:
|
||||||
state.moved_this_turn = True
|
"""Complete a run round: extend the streak and set the running flag."""
|
||||||
|
state.run_rounds += 1
|
||||||
state.running = True
|
state.running = True
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: run")
|
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(
|
def _dispatch_action(
|
||||||
self, state: CombatantState, target: CombatantState | None, action: 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
|
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty
|
||||||
denies_dex = (
|
denies_dex = (
|
||||||
self.has_condition_flag(target, "denies_dex_to_ac")
|
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")
|
or self._has_condition_named(caster, "invisible")
|
||||||
)
|
)
|
||||||
if denies_dex:
|
if denies_dex:
|
||||||
@@ -6444,7 +6585,7 @@ class CombatEngine:
|
|||||||
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
|
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
|
||||||
denies_dex = (
|
denies_dex = (
|
||||||
self.has_condition_flag(target, "denies_dex_to_ac")
|
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")
|
or self._has_condition_named(caster, "invisible")
|
||||||
)
|
)
|
||||||
if denies_dex:
|
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``,
|
moving up to 4x speed toward ``target_id`` or an explicit ``destination``,
|
||||||
reusing the move machinery (Dijkstra descent, step-by-step AoO walk); it
|
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
|
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
|
exposed through the U1 feedback contract (``missing_target`` when neither a
|
||||||
target nor a destination is given) and the three runtime fields are additive
|
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.
|
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 collections.abc import Callable
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
from pf1e_simulator.combat import (
|
from pf1e_simulator.combat import (
|
||||||
Action,
|
Action,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
@@ -46,9 +51,12 @@ def _combatant(
|
|||||||
ac: int = 10,
|
ac: int = 10,
|
||||||
flat_ac: int | None = None,
|
flat_ac: int | None = None,
|
||||||
dex: int = 10,
|
dex: int = 10,
|
||||||
|
con: int = 10,
|
||||||
speed: int = 30,
|
speed: int = 30,
|
||||||
attack_bonus: int = 5,
|
attack_bonus: int = 5,
|
||||||
ranged: bool = False,
|
ranged: bool = False,
|
||||||
|
run_feat: bool = False,
|
||||||
|
creature_type: str = "living",
|
||||||
) -> Combatant:
|
) -> Combatant:
|
||||||
weapon = AttackSpec(
|
weapon = AttackSpec(
|
||||||
id=f"{id_}-{'bow' if ranged else 'sword'}",
|
id=f"{id_}-{'bow' if ranged else 'sword'}",
|
||||||
@@ -64,7 +72,7 @@ def _combatant(
|
|||||||
name=id_,
|
name=id_,
|
||||||
level=1,
|
level=1,
|
||||||
abilities=AbilityScores(
|
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,
|
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),
|
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,
|
hp_max=100,
|
||||||
saves=Saves(fort=0, ref=0, will=0),
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
attacks=[weapon],
|
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_attack_bonus == 0
|
||||||
assert state.aid_ac_bonus == 0
|
assert state.aid_ac_bonus == 0
|
||||||
assert state.running is False
|
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