diff --git a/README.md b/README.md index 2c3c7f0..ca1f91c 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,11 @@ Règles modélisées : compartiment du budget — standard, mouvement, swift, round complet, ou libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget (≤ 1 standard **ou** 1 round complet, ≤ 1 mouvement, ≤ 1 swift ; un round - complet consacre à la fois le standard ET le mouvement). La dégradation est + complet consacre à la fois le standard ET le mouvement). **Conversion + standard → mouvement** (CRB : une action de mouvement peut se prendre « à la + place » de l'action standard) : quand le compartiment mouvement est déjà + occupé et que le standard est encore libre, un second mouvement consomme le + standard — `(move, stand_up)` et `(move, move)` sont donc légaux. La dégradation est **déterministe et stable en ordre** : parcours glouton dans l'ordre, chaque action est gardée tant que son compartiment est libre ; le résultat est donc le plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4dcf9b4..13a210f 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -5900,32 +5900,38 @@ def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: Greedy in-order walk: each action is kept while its budget slot is free (one standard, one move, one swift; a full-round action is only legal while BOTH the standard and the move slot are still free and consumes - them all). The kept sequence is therefore the earliest order-stable legal + them all). PF1e (CRB, "Actions in Combat"): a move action can be taken + "instead of" the standard one — when the move slot is already spent and + the standard slot is still free, a further move action converts the + standard slot (so (move, stand_up) and (move, move) are legal). The kept + sequence is therefore the earliest order-stable legal prefix-subsequence; every skipped action is deterministic. """ kept: list[Action] = [] - standard_used = False - move_used = False - swift_used = False + spent: dict[_ActionEconomySlot, bool] = { + "standard": False, + "move": False, + "swift": False, + "full_round": False, + "free": False, + } for action in actions: slot = _action_economy_slot(action.kind) - if slot == "standard": - if standard_used: + if slot == "free": + kept.append(action) + continue + if slot == "full_round": + if spent["standard"] or spent["move"]: continue - standard_used = True - elif slot == "move": - if move_used: - continue - move_used = True - elif slot == "swift": - if swift_used: - continue - swift_used = True - elif slot == "full_round": - if standard_used or move_used: - continue - standard_used = True - move_used = True + spent["standard"] = True + spent["move"] = True + kept.append(action) + continue + if slot == "move" and spent["move"]: + slot = "standard" # conversion: a move action consumes the standard slot + if spent[slot]: + continue + spent[slot] = True kept.append(action) return tuple(kept) diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index 06b460a..e1e4dfc 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -144,9 +144,19 @@ def test_legal_sequences_are_kept_unchanged() -> None: def test_two_moves_conflict_but_free_steps_do_not() -> None: - # stand_up is a move action: a second move is surplus. + # PF1e conversion: a move action taken while the move slot is spent + # consumes the standard slot instead — two moves are legal, so + # (move, stand_up) is kept whole. actions = (_foe("move"), _foe("stand_up")) - assert _action_economy_filter(actions) == (_foe("move"),) + assert _action_economy_filter(actions) == actions + moves = (_foe("move"), _foe("move")) + assert _action_economy_filter(moves) == moves + # The third move has neither slot left: standard and move both spent. + assert _action_economy_filter((*moves, _foe("attack"))) == moves + assert _action_economy_filter((*moves, _foe("full_attack"))) == moves + # run is full-round: it cannot follow a move (both slots needed upfront). + assert _action_economy_filter((_foe("move"), _foe("run"))) == (_foe("move"),) + assert _action_economy_filter((_foe("run"), _foe("move"))) == (_foe("run"),) # 5foot_step is free: two steps do not consume the move slot. steps = (_foe("5foot_step"), _foe("5foot_step")) assert _action_economy_filter(steps) == steps