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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user