fix(rules): classify run as a full-round action

PF1e CRB: running is a full-round action, not a move action. The budget
filter now classifies run as full-round (consumes the standard AND the
move slot), _FULL_ROUND_KINDS includes it for the staggered filter, and
the nauseated filter drops it (run is no longer move-like). The
moved_this_turn/running flags are unchanged; the _execute_run
moved_this_turn early return stays as defense in depth. default_policy
never emits run, so the golden transcripts are untouched.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 13:48:58 +02:00
parent 9c6f5e277e
commit e44ec75f2b
4 changed files with 28 additions and 19 deletions
+7 -7
View File
@@ -374,13 +374,13 @@ Règles modélisées :
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec` `cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`, standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`, `total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement), `retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une `repair_weapon` ; **swift** = `swift`, `deed`,
mécanique de mouvement posant `moved_this_turn` et déjà exclu de `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
`_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`, `full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, `run` (round complet en RAW — il consacre le standard ET le mouvement, tout
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ; en posant `moved_this_turn` et `running`) ;
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ; **libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
+12 -7
View File
@@ -3393,6 +3393,10 @@ class CombatEngine:
""" """
kind = "run" kind = "run"
if state.moved_this_turn: if state.moved_this_turn:
# Defense in depth, mirroring the _move/_5foot_step no-op-ok
# convention. Unreachable through the filtered path: run is a
# full-round action, so no move-slot action can precede it in a
# kept sequence (the budget filter would have dropped one).
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if self.has_condition_flag(state, "cant_move"): if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
@@ -5781,7 +5785,9 @@ def _choose_spell_action(
return None return None
_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"}) _FULL_ROUND_KINDS: frozenset[str] = frozenset(
{"full_attack", "charge", "withdraw", "full_round", "run"}
)
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"}) _SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
# PF1e (CRB): a nauseated creature can take only a single move action per # PF1e (CRB): a nauseated creature can take only a single move action per
# turn — no standard, no full-round, no attacks, no spellcasting. # turn — no standard, no full-round, no attacks, no spellcasting.
@@ -5843,12 +5849,11 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
# SpellSpec has no cast_time field, so every spell casts as a standard), # SpellSpec has no cast_time field, so every spell casts as a standard),
# maneuver, channel_energy, hex, delay, ready, total_defense, aid_another # maneuver, channel_energy, hex, delay, ready, total_defense, aid_another
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action), # - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
# repair_weapon, run (a full-round action in RAW, but this engine models it # repair_weapon
# as move machinery that sets moved_this_turn and already excludes it from
# _FULL_ROUND_KINDS — kept consistent, see README)
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds) # - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
# - full-round: full_attack, charge, withdraw, full_round, spell_combat # - full-round: full_attack, charge, withdraw, full_round, spell_combat
# (spell + full attack in one round); consumes the standard AND the move slot # (spell + full attack in one round), run (4x-speed movement, PF1e full-round);
# consumes the standard AND the move slot
# - free/conditional (everything else, no budget): wait, free, immediate # - free/conditional (everything else, no budget): wait, free, immediate
# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's # (off-turn; it consumes the NEXT swift via immediate_used, not this turn's
# slots), 5foot_step (free step, mutually exclusive with other movement via # slots), 5foot_step (free step, mutually exclusive with other movement via
@@ -5860,13 +5865,13 @@ _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
"delay", "ready", "total_defense", "aid_another", "delay", "ready", "total_defense", "aid_another",
}) })
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({ _ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
"move", "stand_up", "retrieve_weapon", "repair_weapon", "run", "move", "stand_up", "retrieve_weapon", "repair_weapon",
}) })
_ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset( _ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
{"swift", "deed", "inspire_courage"} {"swift", "deed", "inspire_courage"}
) )
_ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({ _ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({
"full_attack", "charge", "withdraw", "full_round", "spell_combat", "full_attack", "charge", "withdraw", "full_round", "spell_combat", "run",
}) })
_ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({ _ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({
"wait", "free", "immediate", "5foot_step", "drop_prone", "wait", "free", "immediate", "5foot_step", "drop_prone",
+7 -2
View File
@@ -438,12 +438,17 @@ def test_run_and_aid_without_target_are_rejected() -> None:
eng = _duo(policy, [10, 5], round_cap=1) eng = _duo(policy, [10, 5], round_cap=1)
t = eng.run().transcript t = eng.run().transcript
hero = eng._states[0] hero = eng._states[0]
# run is a full-round action: it consumes the standard AND the move slot,
# so the budget filter drops the following aid_another as a surplus
# standard before it can even reach the missing-target gate.
assert hero.last_action_results == [ assert hero.last_action_results == [
ActionResult(
kind="aid_another", ok=False, reason="downgraded:action_economy"
),
ActionResult(kind="run", ok=False, reason="missing_target"), ActionResult(kind="run", ok=False, reason="missing_target"),
ActionResult(kind="aid_another", ok=False, reason="missing_target"),
] ]
assert "round 1 hero: run rejected (missing_target)" in t assert "round 1 hero: run rejected (missing_target)" in t
assert "round 1 hero: aid_another rejected (missing_target)" in t assert "round 1 hero: aid_another rejected (missing_target)" not in t
assert hero.pos == (0, 0) assert hero.pos == (0, 0)
assert hero.running is False assert hero.running is False
+2 -3
View File
@@ -92,9 +92,8 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None:
# move actions # move actions
"move": "move", "stand_up": "move", "retrieve_weapon": "move", "move": "move", "stand_up": "move", "retrieve_weapon": "move",
"repair_weapon": "move", "repair_weapon": "move",
# run: RAW full-round, but the engine models it as move machinery # run: PF1e full-round action (4x speed); consumes standard + move.
# (sets moved_this_turn, excluded from _FULL_ROUND_KINDS) — consistent. "run": "full_round",
"run": "move",
# swift actions # swift actions
"swift": "swift", "deed": "swift", "inspire_courage": "swift", "swift": "swift", "deed": "swift", "inspire_courage": "swift",
# full-round actions # full-round actions