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
+44 -2
View File
@@ -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,
+144 -9
View File
@@ -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.
+378
View File
@@ -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)