feat(rules): allow a move action in place of a standard action

PF1e CRB (Actions in Combat): a move action can be taken instead of the
standard one. The budget filter now converts a second move action onto
the standard slot when the move slot is spent and the standard is still
free, so (move, stand_up) and (move, move) are legal; a third move (or
any further standard) is still surplus. The greedy walk is expressed
with a spent-slot map to stay inside the C901 complexity ceiling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 13:53:45 +02:00
parent e44ec75f2b
commit 0997c8d1b5
3 changed files with 43 additions and 23 deletions
+5 -1
View File
@@ -360,7 +360,11 @@ Règles modélisées :
compartiment du budget — standard, mouvement, swift, round complet, ou compartiment du budget — standard, mouvement, swift, round complet, ou
libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget 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 (≤ 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 **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 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 plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et
+26 -20
View File
@@ -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 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 (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 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. prefix-subsequence; every skipped action is deterministic.
""" """
kept: list[Action] = [] kept: list[Action] = []
standard_used = False spent: dict[_ActionEconomySlot, bool] = {
move_used = False "standard": False,
swift_used = False "move": False,
"swift": False,
"full_round": False,
"free": False,
}
for action in actions: for action in actions:
slot = _action_economy_slot(action.kind) slot = _action_economy_slot(action.kind)
if slot == "standard": if slot == "free":
if standard_used: kept.append(action)
continue
if slot == "full_round":
if spent["standard"] or spent["move"]:
continue continue
standard_used = True spent["standard"] = True
elif slot == "move": spent["move"] = True
if move_used: kept.append(action)
continue continue
move_used = True if slot == "move" and spent["move"]:
elif slot == "swift": slot = "standard" # conversion: a move action consumes the standard slot
if swift_used: if spent[slot]:
continue continue
swift_used = True spent[slot] = True
elif slot == "full_round":
if standard_used or move_used:
continue
standard_used = True
move_used = True
kept.append(action) kept.append(action)
return tuple(kept) return tuple(kept)
+12 -2
View File
@@ -144,9 +144,19 @@ def test_legal_sequences_are_kept_unchanged() -> None:
def test_two_moves_conflict_but_free_steps_do_not() -> 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")) 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. # 5foot_step is free: two steps do not consume the move slot.
steps = (_foe("5foot_step"), _foe("5foot_step")) steps = (_foe("5foot_step"), _foe("5foot_step"))
assert _action_economy_filter(steps) == steps assert _action_economy_filter(steps) == steps