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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
continue
|
||||||
standard_used = True
|
if slot == "full_round":
|
||||||
elif slot == "move":
|
if spent["standard"] or spent["move"]:
|
||||||
if move_used:
|
|
||||||
continue
|
continue
|
||||||
move_used = True
|
spent["standard"] = True
|
||||||
elif slot == "swift":
|
spent["move"] = True
|
||||||
if swift_used:
|
kept.append(action)
|
||||||
continue
|
continue
|
||||||
swift_used = True
|
if slot == "move" and spent["move"]:
|
||||||
elif slot == "full_round":
|
slot = "standard" # conversion: a move action consumes the standard slot
|
||||||
if standard_used or move_used:
|
if spent[slot]:
|
||||||
continue
|
continue
|
||||||
standard_used = True
|
spent[slot] = True
|
||||||
move_used = True
|
|
||||||
kept.append(action)
|
kept.append(action)
|
||||||
return tuple(kept)
|
return tuple(kept)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user