diff --git a/README.md b/README.md index 700b94e..07faade 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a28c13c..217d623 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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. diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py new file mode 100644 index 0000000..cfd759e --- /dev/null +++ b/tests/test_conformance_action_economy.py @@ -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)