diff --git a/README.md b/README.md index 9910ac7..2c3c7f0 100644 --- a/README.md +++ b/README.md @@ -374,13 +374,13 @@ Règles modélisées : `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 standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`, - `total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`, - `retrieve_weapon` (sortir une arme est une action de mouvement), - `repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une - mécanique de mouvement posant `moved_this_turn` et déjà exclu de - `_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`, - `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, - `full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ; + `total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`, + `retrieve_weapon` (sortir une arme est une action de mouvement), + `repair_weapon` ; **swift** = `swift`, `deed`, + `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, + `full_round`, `spell_combat` (sort + attaque à outrance dans le même round), + `run` (round complet en RAW — il consacre le standard ET le mouvement, tout + en posant `moved_this_turn` et `running`) ; **libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ; il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 8d2bce3..4dcf9b4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3393,6 +3393,10 @@ class CombatEngine: """ kind = "run" 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) if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") @@ -5781,7 +5785,9 @@ def _choose_spell_action( 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"}) # PF1e (CRB): a nauseated creature can take only a single move action per # 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), # maneuver, channel_energy, hex, delay, ready, total_defense, aid_another # - 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 -# as move machinery that sets moved_this_turn and already excludes it from -# _FULL_ROUND_KINDS — kept consistent, see README) +# repair_weapon # - swift: swift, deed, inspire_courage (the engine's swift bonus kinds) # - 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 # (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 @@ -5860,13 +5865,13 @@ _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({ "delay", "ready", "total_defense", "aid_another", }) _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( {"swift", "deed", "inspire_courage"} ) _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({ "wait", "free", "immediate", "5foot_step", "drop_prone", diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index a5d9779..545ff65 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -438,12 +438,17 @@ def test_run_and_aid_without_target_are_rejected() -> None: eng = _duo(policy, [10, 5], round_cap=1) t = eng.run().transcript 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 == [ + ActionResult( + kind="aid_another", ok=False, reason="downgraded:action_economy" + ), 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: 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.running is False diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index cfd759e..06b460a 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -92,9 +92,8 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None: # move actions "move": "move", "stand_up": "move", "retrieve_weapon": "move", "repair_weapon": "move", - # run: RAW full-round, but the engine models it as move machinery - # (sets moved_this_turn, excluded from _FULL_ROUND_KINDS) — consistent. - "run": "move", + # run: PF1e full-round action (4x speed); consumes standard + move. + "run": "full_round", # swift actions "swift": "swift", "deed": "swift", "inspire_courage": "swift", # full-round actions