fix(rules): enforce the action-economy budget on policy output

Apply the PF1e per-turn action budget (CRB Actions in Combat: one
standard + one move, or one full-round in place of both, plus one
swift) unconditionally to every sequence a policy returns, not only
the immediate/staggered/nauseated specializations.

- _action_economy_filter: greedy in-order walk keeping the earliest
  legal prefix-subsequence; a full-round action is only kept while
  both the standard and move slots are still free.
- Exhaustive kind->slot classification (standard / move / swift /
  full_round / free-conditional) over the Action.kind Literal,
  documented and locked by tests.
- Surplus actions dropped and reported as
  ActionResult(ok=False, reason="downgraded:action_economy") via a
  multiset drop diff (reports duplicate-kind surplus the set-based
  _dropped_kinds cannot).
- Composition order: general budget first, then the existing
  immediate/staggered/nauseated filters (unchanged).
- default_policy untouched and its turns verified legal by the
  validator; golden transcripts byte-identical.
This commit is contained in:
2026-09-11 12:26:49 +02:00
parent a4814b935b
commit b7c80babfd
3 changed files with 566 additions and 11 deletions
+144 -9
View File
@@ -3,7 +3,12 @@
Phase 0 documented deviations from PF1e (conventions):
- Action economy: a normal turn grants one standard action + one move action
(or one full-round action), plus swift/free/immediate. The policy returns an
ordered sequence of `Action` per turn; the engine executes them in order,
ordered sequence of `Action` per turn; the engine enforces the budget on it
(X4, `_action_economy_filter`): surplus standards/moves/swifts and a
full-round action conflicting with an already-spent standard/move are
dropped in order (earliest legal prefix-subsequence kept) and reported as
`downgraded:action_economy`; the condition-specific filters then apply as
before. The engine executes the remaining actions in order,
stopping early if the actor drops or one side is wiped. `default_policy`
returns `(full_attack,)` when already in range, `(charge,)` when a
straight-line charge path exists, `(5foot_step, full_attack)` when a single
@@ -96,7 +101,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
from pf1e_simulator.dice import dice_to_str
@@ -118,8 +123,6 @@ from pf1e_simulator.spells import (
)
if TYPE_CHECKING:
from typing import Literal
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos, TerrainType
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
@@ -748,11 +751,15 @@ class Action:
# weapon_id that cannot be honoured. U7b adds invalid_destination for
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
# no_weapon (aid another without a usable melee weapon) and
# unreachable_destination (run to an unhonourable destination).
# unreachable_destination (run to an unhonourable destination). X4 adds
# downgraded:action_economy for actions the general per-turn budget filter
# drops from the policy sequence (second standard, second move, second
# swift, full-round conflicting with standard+move).
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource",
"downgraded:staggered", "downgraded:nauseated",
"downgraded:action_economy",
"delayed", "already_delayed", "ready",
"not_prone", "already_prone", "unreachable_destination",
"weapon_unavailable", "invalid_destination",
@@ -763,6 +770,7 @@ _REASON_MISSING_TARGET = "missing_target"
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
_REASON_DELAYED = "delayed"
_REASON_ALREADY_DELAYED = "already_delayed"
_REASON_READY = "ready"
@@ -1396,11 +1404,24 @@ class CombatEngine:
) -> tuple[Action, ...]:
"""Downgrade the policy's action sequence to the legal action economy.
Swift consumed by an immediate action; PF1e (CRB, Disabled): a
creature at exactly 0 hp is staggered — one move OR one standard
action per round, no full-round actions; nauseated: single move only.
Dropped kinds are reported into ``results``.
Composition order (X4): the general per-turn budget (CRB, "Actions in
Combat": one standard + one move, or one full-round, plus one swift)
is enforced first on every sequence; the condition-specific
specializations then run on the budget-legal remainder (their output
is a single action, so the budget never re-drops it). Swift consumed
by an immediate action; PF1e (CRB, Disabled): a creature at exactly
0 hp is staggered — one move OR one standard action per round, no
full-round actions; nauseated: single move only. Dropped kinds are
reported into ``results``.
"""
kept = _action_economy_filter(actions)
results.extend(
ActionResult(
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
)
for kind in _dropped_kinds_multiset(actions, kept)
)
actions = kept
if state.immediate_used:
kept = _without_swift_bonus(actions)
results.extend(
@@ -5772,6 +5793,120 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
return ()
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
# action + one move action (or one full-round action in place of both), plus
# one swift action; free and conditional actions never consume the budget.
# Every Action kind is classified into exactly one slot (exhaustive over the
# Action.kind Literal, locked by tests/test_conformance_action_economy.py):
# - standard: attack, cast_spell (the engine models no spell casting times —
# 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)
# - 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
# - 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
# moved_this_turn), drop_prone, activate_power_attack and fight_defensively
# (mode declarations applied to the attack that follows them, like the
# Power Attack toggle), summon is not a kind (summons arrive via cast_spell).
_ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
"attack", "cast_spell", "maneuver", "channel_energy", "hex",
"delay", "ready", "total_defense", "aid_another",
})
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
"move", "stand_up", "retrieve_weapon", "repair_weapon", "run",
})
_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",
})
_ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({
"wait", "free", "immediate", "5foot_step", "drop_prone",
"activate_power_attack", "fight_defensively",
})
_ActionEconomySlot = Literal["standard", "move", "swift", "full_round", "free"]
def _action_economy_slot(kind: str) -> _ActionEconomySlot:
"""The budget slot one action kind consumes (free if none applies)."""
if kind in _ACTION_ECONOMY_STANDARD_KINDS:
return "standard"
if kind in _ACTION_ECONOMY_MOVE_KINDS:
return "move"
if kind in _ACTION_ECONOMY_SWIFT_KINDS:
return "swift"
if kind in _ACTION_ECONOMY_FULL_ROUND_KINDS:
return "full_round"
return "free"
def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
"""Degrade a policy sequence to the legal PF1e action economy.
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
prefix-subsequence; every skipped action is deterministic.
"""
kept: list[Action] = []
standard_used = False
move_used = False
swift_used = False
for action in actions:
slot = _action_economy_slot(action.kind)
if slot == "standard":
if standard_used:
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
kept.append(action)
return tuple(kept)
def _dropped_kinds_multiset(
before: tuple[Action, ...], after: tuple[Action, ...]
) -> tuple[str, ...]:
"""One dropped-kind entry per dropped action instance (in order).
Unlike ``_dropped_kinds`` (set membership, which cannot report dropping
the second of two same-kind actions), this consumes the kept instances
first and reports every surplus — the budget filter regularly drops a
duplicate standard/move/swift action.
"""
remaining: dict[str, int] = {}
for action in after:
remaining[action.kind] = remaining.get(action.kind, 0) + 1
dropped: list[str] = []
for action in before:
count = remaining.get(action.kind, 0)
if count > 0:
remaining[action.kind] = count - 1
else:
dropped.append(action.kind)
return tuple(dropped)
def _ready_payload(action: Action) -> Action:
"""Derive the concrete payload of a ready action from its carrier fields.