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:
@@ -311,6 +311,45 @@ Règles modélisées :
|
|||||||
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
||||||
(hors-tour, consomment le prochain swift) sont modélisées via une politique
|
(hors-tour, consomment le prochain swift) sont modélisées via une politique
|
||||||
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
||||||
|
**Application du budget (conformance X4)** : le moteur applique désormais le
|
||||||
|
budget d'action PF1e (CRB « Actions in Combat ») à TOUTE séquence renvoyée par
|
||||||
|
une politique, y compris une future politique LLM, et non plus seulement aux
|
||||||
|
cas immédiat/accablé/nausée. Chaque kind d'action est classé dans un seul
|
||||||
|
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
|
||||||
|
**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
|
||||||
|
rapportée comme `ActionResult(kind=…, ok=False, reason="downgraded:action_economy")`
|
||||||
|
(visible dans `last_action_results` et le snapshot `last_turn_results`). Exemples :
|
||||||
|
`(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ;
|
||||||
|
`(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` →
|
||||||
|
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
|
||||||
|
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
|
||||||
|
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
|
||||||
|
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
|
||||||
|
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
|
||||||
|
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
||||||
|
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
|
||||||
|
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||||
|
`repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une
|
||||||
|
mécanique de mouvement posant `moved_this_turn` et déjà exclu de
|
||||||
|
`_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`,
|
||||||
|
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||||
|
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ;
|
||||||
|
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
|
||||||
|
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
|
||||||
|
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
|
||||||
|
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
|
||||||
|
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
|
||||||
|
basculement Power Attack). Ordre de composition : le budget général est
|
||||||
|
appliqué EN PREMIER, puis les filtres spécialisés (immédiat/accablé/nausée)
|
||||||
|
s'exercent sur le reste déjà légal (leur sortie est une action unique, donc
|
||||||
|
jamais re-filtrée par le budget). L'ordre de dégradation est celui de la
|
||||||
|
politique : la première action légale d'un compartiment gagne, les suivantes
|
||||||
|
sont excédentaires.
|
||||||
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
|
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
|
||||||
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
|
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
|
||||||
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
|
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
|
||||||
@@ -395,8 +434,11 @@ Règles modélisées :
|
|||||||
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
||||||
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
||||||
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
||||||
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
par le budget général d'action (X4 — action excédentaire retirée de la
|
||||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
séquence de la politique) ou par les filtres staggered/nauseated (ou swift
|
||||||
|
consommé par une immédiate) →
|
||||||
|
`downgraded:action_economy` / `downgraded:staggered` / `downgraded:nauseated` /
|
||||||
|
`downgraded:immediate_swift` ;
|
||||||
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
||||||
`ok=False, reason="already_delayed"` ; préparation valide →
|
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||||
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
Phase 0 documented deviations from PF1e (conventions):
|
Phase 0 documented deviations from PF1e (conventions):
|
||||||
- Action economy: a normal turn grants one standard action + one move action
|
- 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
|
(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`
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||||
returns `(full_attack,)` when already in range, `(charge,)` when a
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||||
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
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 collections.abc import Callable, Iterable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
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.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
from pf1e_simulator.dice import dice_to_str
|
from pf1e_simulator.dice import dice_to_str
|
||||||
@@ -118,8 +123,6 @@ from pf1e_simulator.spells import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import Pos, TerrainType
|
from pf1e_simulator.map import Pos, TerrainType
|
||||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
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
|
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
||||||
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
||||||
# no_weapon (aid another without a usable melee weapon) and
|
# 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({
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||||
"downgraded:staggered", "downgraded:nauseated",
|
"downgraded:staggered", "downgraded:nauseated",
|
||||||
|
"downgraded:action_economy",
|
||||||
"delayed", "already_delayed", "ready",
|
"delayed", "already_delayed", "ready",
|
||||||
"not_prone", "already_prone", "unreachable_destination",
|
"not_prone", "already_prone", "unreachable_destination",
|
||||||
"weapon_unavailable", "invalid_destination",
|
"weapon_unavailable", "invalid_destination",
|
||||||
@@ -763,6 +770,7 @@ _REASON_MISSING_TARGET = "missing_target"
|
|||||||
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
||||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||||
|
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||||
_REASON_DELAYED = "delayed"
|
_REASON_DELAYED = "delayed"
|
||||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||||
_REASON_READY = "ready"
|
_REASON_READY = "ready"
|
||||||
@@ -1396,11 +1404,24 @@ class CombatEngine:
|
|||||||
) -> tuple[Action, ...]:
|
) -> tuple[Action, ...]:
|
||||||
"""Downgrade the policy's action sequence to the legal action economy.
|
"""Downgrade the policy's action sequence to the legal action economy.
|
||||||
|
|
||||||
Swift consumed by an immediate action; PF1e (CRB, Disabled): a
|
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
||||||
creature at exactly 0 hp is staggered — one move OR one standard
|
Combat": one standard + one move, or one full-round, plus one swift)
|
||||||
action per round, no full-round actions; nauseated: single move only.
|
is enforced first on every sequence; the condition-specific
|
||||||
Dropped kinds are reported into ``results``.
|
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:
|
if state.immediate_used:
|
||||||
kept = _without_swift_bonus(actions)
|
kept = _without_swift_bonus(actions)
|
||||||
results.extend(
|
results.extend(
|
||||||
@@ -5772,6 +5793,120 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|||||||
return ()
|
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:
|
def _ready_payload(action: Action) -> Action:
|
||||||
"""Derive the concrete payload of a ready action from its carrier fields.
|
"""Derive the concrete payload of a ready action from its carrier fields.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Conformance X4: the general per-turn action-economy budget.
|
||||||
|
|
||||||
|
PF1e CRB "Actions in Combat": one standard action + one move action (or one
|
||||||
|
full-round action in place of both), plus one swift action per turn; free and
|
||||||
|
conditional actions never consume the budget. ``_action_economy_filter``
|
||||||
|
enforces this on EVERY policy-returned sequence (unconditionally, not only
|
||||||
|
for the immediate/staggered/nauseated specializations): surplus actions are
|
||||||
|
dropped in order (the earliest legal prefix-subsequence is kept) and reported
|
||||||
|
as ``downgraded:action_economy`` in ``last_action_results``. ``default_policy``
|
||||||
|
turns are legal by construction — verified here so the goldens stay frozen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
_ACTION_ECONOMY_FREE_KINDS,
|
||||||
|
_ACTION_ECONOMY_FULL_ROUND_KINDS,
|
||||||
|
_ACTION_ECONOMY_MOVE_KINDS,
|
||||||
|
_ACTION_ECONOMY_STANDARD_KINDS,
|
||||||
|
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||||
|
ACTION_RESULT_REASONS,
|
||||||
|
Action,
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
_action_economy_filter,
|
||||||
|
_action_economy_slot,
|
||||||
|
_dropped_kinds,
|
||||||
|
_dropped_kinds_multiset,
|
||||||
|
default_policy,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||||
|
from pf1e_simulator.effects import ActiveCondition
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from tests.test_combat import make_combatant, make_state
|
||||||
|
from tests.test_observation_enriched import make_grid
|
||||||
|
|
||||||
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
|
_Kind = Literal[
|
||||||
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
|
"activate_power_attack", "delay", "ready", "total_defense",
|
||||||
|
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run",
|
||||||
|
]
|
||||||
|
|
||||||
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
||||||
|
|
||||||
|
|
||||||
|
def _foe(kind: _Kind) -> Action:
|
||||||
|
return Action(kind=kind, target_id="foe")
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) the kind -> slot classification is exhaustive and disjoint ───────────
|
||||||
|
|
||||||
|
_ALL_KINDS: tuple[_Kind, ...] = (
|
||||||
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||||
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
|
"activate_power_attack", "delay", "ready", "total_defense",
|
||||||
|
"fight_defensively", "stand_up", "drop_prone", "aid_another", "run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_action_kind_is_classified_exactly_once() -> None:
|
||||||
|
groups = (
|
||||||
|
_ACTION_ECONOMY_STANDARD_KINDS,
|
||||||
|
_ACTION_ECONOMY_MOVE_KINDS,
|
||||||
|
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||||
|
_ACTION_ECONOMY_FULL_ROUND_KINDS,
|
||||||
|
_ACTION_ECONOMY_FREE_KINDS,
|
||||||
|
)
|
||||||
|
covered = frozenset().union(*groups)
|
||||||
|
assert covered == frozenset(_ALL_KINDS)
|
||||||
|
# Pairwise disjoint: no kind consumes two slots.
|
||||||
|
total = sum(len(g) for g in groups)
|
||||||
|
assert total == len(covered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_per_kind_matches_pf1e_action_type() -> None:
|
||||||
|
expected: dict[str, str] = {
|
||||||
|
# standard actions
|
||||||
|
"attack": "standard", "cast_spell": "standard", "maneuver": "standard",
|
||||||
|
"channel_energy": "standard", "hex": "standard", "delay": "standard",
|
||||||
|
"ready": "standard", "total_defense": "standard",
|
||||||
|
"aid_another": "standard",
|
||||||
|
# move actions
|
||||||
|
"move": "move", "stand_up": "move", "retrieve_weapon": "move",
|
||||||
|
"repair_weapon": "move",
|
||||||
|
# run: RAW full-round, but the engine models it as move machinery
|
||||||
|
# (sets moved_this_turn, excluded from _FULL_ROUND_KINDS) — consistent.
|
||||||
|
"run": "move",
|
||||||
|
# swift actions
|
||||||
|
"swift": "swift", "deed": "swift", "inspire_courage": "swift",
|
||||||
|
# full-round actions
|
||||||
|
"full_attack": "full_round", "charge": "full_round",
|
||||||
|
"withdraw": "full_round", "full_round": "full_round",
|
||||||
|
"spell_combat": "full_round",
|
||||||
|
# free / conditional — no budget
|
||||||
|
"wait": "free", "free": "free", "immediate": "free",
|
||||||
|
"5foot_step": "free", "drop_prone": "free",
|
||||||
|
"activate_power_attack": "free", "fight_defensively": "free",
|
||||||
|
}
|
||||||
|
assert set(expected) == set(_ALL_KINDS)
|
||||||
|
for kind, slot in expected.items():
|
||||||
|
assert _action_economy_slot(kind) == slot, kind
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_reason_is_in_the_result_vocabulary() -> None:
|
||||||
|
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) legal sequences pass through untouched ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_legal_sequences_are_kept_unchanged() -> None:
|
||||||
|
legal: tuple[tuple[_Kind, ...], ...] = (
|
||||||
|
("attack", "move"),
|
||||||
|
("move", "attack"),
|
||||||
|
("deed", "attack"),
|
||||||
|
("swift", "attack"),
|
||||||
|
("full_attack",),
|
||||||
|
("charge",),
|
||||||
|
("withdraw",),
|
||||||
|
("run",),
|
||||||
|
("5foot_step", "full_attack"),
|
||||||
|
("move", "5foot_step"),
|
||||||
|
("5foot_step", "move"),
|
||||||
|
("inspire_courage", "spell_combat"),
|
||||||
|
("activate_power_attack", "full_attack"),
|
||||||
|
("drop_prone", "attack"),
|
||||||
|
("fight_defensively", "attack"),
|
||||||
|
("deed", "move", "attack"),
|
||||||
|
("wait",),
|
||||||
|
)
|
||||||
|
for kinds in legal:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
assert _action_economy_filter(actions) == actions, kinds
|
||||||
|
assert _dropped_kinds_multiset(actions, actions) == (), kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_moves_conflict_but_free_steps_do_not() -> None:
|
||||||
|
# stand_up is a move action: a second move is surplus.
|
||||||
|
actions = (_foe("move"), _foe("stand_up"))
|
||||||
|
assert _action_economy_filter(actions) == (_foe("move"),)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c) illegal sequences degrade to the earliest legal prefix ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_illegal_sequences_degrade_deterministically() -> None:
|
||||||
|
cases: tuple[tuple[tuple[_Kind, ...], tuple[_Kind, ...]], ...] = (
|
||||||
|
# two standards — the second is surplus
|
||||||
|
(("attack", "attack"), ("attack",)),
|
||||||
|
# charge (full-round) then a standard attack
|
||||||
|
(("charge", "attack"), ("charge",)),
|
||||||
|
# standard spent, then a full-round conflicting with standard+move
|
||||||
|
(("attack", "full_attack", "move"), ("attack", "move")),
|
||||||
|
# two moves around a standard
|
||||||
|
(("move", "attack", "move"), ("move", "attack")),
|
||||||
|
# two swifts — the second is surplus
|
||||||
|
(("full_attack", "deed", "inspire_courage"), ("full_attack", "deed")),
|
||||||
|
# full-round then a move (charge consumes standard+move)
|
||||||
|
(("full_attack", "move"), ("full_attack",)),
|
||||||
|
# two full-round actions
|
||||||
|
(("charge", "withdraw"), ("charge",)),
|
||||||
|
# move first, then a full-round that needed the move slot
|
||||||
|
(("move", "charge"), ("move",)),
|
||||||
|
# three standards
|
||||||
|
(("attack", "cast_spell", "maneuver"), ("attack",)),
|
||||||
|
)
|
||||||
|
for kinds, kept_kinds in cases:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
expected = tuple(_foe(k) for k in kept_kinds)
|
||||||
|
assert _action_economy_filter(actions) == expected, kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiset_drop_reports_each_surplus_instance() -> None:
|
||||||
|
actions = (_foe("attack"), _foe("attack"))
|
||||||
|
assert _dropped_kinds_multiset(actions, (actions[0],)) == ("attack",)
|
||||||
|
# The set-based _dropped_kinds cannot see the duplicate; the multiset can.
|
||||||
|
assert _dropped_kinds(actions, (actions[0],)) == ()
|
||||||
|
deeds = (_foe("deed"), _foe("deed"), _foe("attack"))
|
||||||
|
kept = (deeds[0], deeds[2])
|
||||||
|
assert _dropped_kinds_multiset(deeds, kept) == ("deed",)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d) interplay with the condition-specific filters ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _duel(policy: PolicyFn, rng: list[int], round_cap: int = 1) -> CombatEngine:
|
||||||
|
hero = make_state(
|
||||||
|
make_combatant("hero", hp=100, initiative_mod=2), "players", (0, 0)
|
||||||
|
)
|
||||||
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 1))
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(rng), make_grid(), [hero, foe],
|
||||||
|
policy=policy, round_cap=round_cap,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_condition(state: CombatantState, name: str) -> None:
|
||||||
|
state.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_staggered_after_budget_keeps_single_legal_action() -> None:
|
||||||
|
# (charge, attack): the budget keeps the charge and drops the attack as
|
||||||
|
# surplus standard; the staggered filter then drops the full-round charge.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="charge", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5])
|
||||||
|
hero = eng._states[0]
|
||||||
|
hero.hp = 0 # disabled -> staggered
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert ("attack", "downgraded:action_economy") in reasons
|
||||||
|
assert ("charge", "downgraded:staggered") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_nauseated_after_budget_keeps_single_move() -> None:
|
||||||
|
# (move, attack, move): the budget drops the second move; the nauseated
|
||||||
|
# filter then keeps only the first move and drops the standard attack.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5])
|
||||||
|
hero = eng._states[0]
|
||||||
|
_add_condition(hero, "nauseated")
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert reasons.count(("move", "downgraded:action_economy")) == 1
|
||||||
|
assert ("attack", "downgraded:nauseated") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_immediate_swift_still_removes_swift_bonus_after_budget() -> None:
|
||||||
|
# (deed, deed, attack): the budget drops the second deed; the consumed
|
||||||
|
# swift (immediate_used) then removes the surviving deed too.
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3])
|
||||||
|
hero = eng._states[0]
|
||||||
|
hero.immediate_used = True
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
reasons = [(r.kind, r.reason) for r in hero.last_action_results]
|
||||||
|
assert ("deed", "downgraded:action_economy") in reasons
|
||||||
|
assert ("deed", "downgraded:immediate_swift") in reasons
|
||||||
|
assert ("attack", "ok") in reasons
|
||||||
|
|
||||||
|
|
||||||
|
# ── (e) end-to-end: an illegal custom policy tuple is degraded by run() ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_degrades_illegal_policy_and_reports_results() -> None:
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
# two standards + two moves: only the first attack and the
|
||||||
|
# first move may execute.
|
||||||
|
return (
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3, 6, 4])
|
||||||
|
result = eng.run()
|
||||||
|
hero = eng._states[0]
|
||||||
|
reasons = [(r.kind, r.ok, r.reason) for r in hero.last_action_results]
|
||||||
|
assert reasons == [
|
||||||
|
("attack", False, "downgraded:action_economy"),
|
||||||
|
("move", False, "downgraded:action_economy"),
|
||||||
|
("attack", True, "ok"),
|
||||||
|
("move", True, "ok"),
|
||||||
|
]
|
||||||
|
# Only one hero swing is resolved in the transcript.
|
||||||
|
swings = [ln for ln in result.transcript if "hero: short sword vs foe" in ln]
|
||||||
|
assert len(swings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_keeps_legal_custom_policy_untouched() -> None:
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if s.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="deed", target_id="foe",
|
||||||
|
deed_name="up_close_and_deadly",
|
||||||
|
),
|
||||||
|
Action(kind="move", target_id="foe"),
|
||||||
|
Action(kind="attack", target_id="foe"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5, 8, 3, 6, 4])
|
||||||
|
eng.run()
|
||||||
|
hero = eng._states[0]
|
||||||
|
for res in hero.last_action_results:
|
||||||
|
assert res.reason != "downgraded:action_economy"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (f) default_policy turns are legal by construction (golden invariant) ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_representative_sequences_pass_the_validator() -> None:
|
||||||
|
shapes: tuple[tuple[_Kind, ...], ...] = (
|
||||||
|
("wait",),
|
||||||
|
("withdraw",),
|
||||||
|
("channel_energy",),
|
||||||
|
("hex",),
|
||||||
|
("cast_spell",),
|
||||||
|
("inspire_courage", "spell_combat"),
|
||||||
|
("deed", "full_attack"),
|
||||||
|
("deed", "charge"),
|
||||||
|
("deed", "5foot_step", "full_attack"),
|
||||||
|
("deed", "move", "attack"),
|
||||||
|
("inspire_courage", "cast_spell"),
|
||||||
|
("full_attack",),
|
||||||
|
("charge",),
|
||||||
|
("5foot_step", "full_attack"),
|
||||||
|
("move", "attack"),
|
||||||
|
)
|
||||||
|
for kinds in shapes:
|
||||||
|
actions = tuple(_foe(k) for k in kinds)
|
||||||
|
assert _action_economy_filter(actions) == actions, kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_combats_never_trigger_the_budget_downgrade() -> None:
|
||||||
|
# All d20 rolls are 1s after initiative: every swing auto-misses, so the
|
||||||
|
# scripted queue cannot exhaust before the round cap.
|
||||||
|
for rng in (
|
||||||
|
[10, 5] + [1] * 10,
|
||||||
|
[8, 6] + [1] * 10,
|
||||||
|
):
|
||||||
|
eng = _duel(default_policy, rng, round_cap=3)
|
||||||
|
eng.run()
|
||||||
|
for state in eng._states:
|
||||||
|
for res in state.last_action_results:
|
||||||
|
assert res.reason != "downgraded:action_economy", (rng, res)
|
||||||
Reference in New Issue
Block a user