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
|
||||
(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.
|
||||
**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 à
|
||||
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
|
||||
@@ -395,8 +434,11 @@ Règles modélisées :
|
||||
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
||||
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
||||
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
||||
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
||||
par le budget général d'action (X4 — action excédentaire retirée de la
|
||||
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 →
|
||||
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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