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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user