diff --git a/README.md b/README.md index caad788..f06cd04 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b2a456a..e42acbc 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index 2804d23..0219fca 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -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