"""Conformance: spell casting times in the engine's turn pipeline. The engine binds its registry-backed resolver (``CombatEngine._cast_time_of``) into the budget filters: a cast_spell occupies the slot its spell's casting time maps to (_cast_time_slot). A long casting time (1+ minutes) cannot be scheduled within a turn, so it is dropped BEFORE the budget filter with the distinct reason ``downgraded:cast_time``. A one-round cast is CLAMPED to the full-round slot (documented approximation: it resolves within the turn instead of spanning rounds). Swift casts compete with the swift bonus (and are removed by a consumed swift); full-round casts are illegal for a staggered creature, and run is illegal for a nauseated one. """ from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.effects import ActiveCondition from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import SpellSpec from tests.test_combat import make_combatant, make_state from tests.test_observation_enriched import make_grid if TYPE_CHECKING: from pf1e_simulator.spells import CastTime PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] _WAIT: tuple[Action, ...] = (Action(kind="wait"),) def _spell(name: str, cast_time: CastTime) -> SpellSpec: return SpellSpec(name=name, level=1, school="evocation", cast_time=cast_time) def _duel( policy: PolicyFn, rng: list[int], registry: dict[str, SpellSpec], 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, spell_registry=registry, ) def _take_hero_turn(eng: CombatEngine) -> list[tuple[str, bool, str]]: hero = eng._states[0] eng._roll_initiative() eng._take_turn(hero) return [(r.kind, r.ok, r.reason) for r in hero.last_action_results] def _add_condition(state: CombatantState, name: str) -> None: state.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test" ) ) def _cast(spell_name: str) -> Action: return Action(kind="cast_spell", target_id="foe", spell_name=spell_name) def _policy(actions: tuple[Action, ...]) -> PolicyFn: def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: return actions if s.combatant.id == "hero" else _WAIT return policy def test_long_cast_is_dropped_with_its_own_reason() -> None: # Given: a spell whose casting time is long (1+ minutes) # When: the policy proposes it as the turn's only action # Then: it never reaches the budget filter; the drop is downgraded:cast_time. eng = _duel(_policy((_cast("Ceremony"),)), [10, 5], {"Ceremony": _spell("Ceremony", "long")}) assert _take_hero_turn(eng) == [ ("cast_spell", False, "downgraded:cast_time") ] def test_one_round_cast_consumes_the_whole_turn() -> None: # Given: a one-round cast (clamped to the full-round slot) # When: a move action follows it in the sequence # Then: the full-round cast spent standard+move; the move is surplus. eng = _duel( _policy((_cast("Summon"), Action(kind="move", target_id="foe"))), [10, 5], {"Summon": _spell("Summon", "one_round")}, ) # The hero has no slots: the cast itself is rejected at execution (no_slot) # but it still consumed the turn — only the move is budget-dropped. assert _take_hero_turn(eng) == [ ("move", False, "downgraded:action_economy"), ("cast_spell", False, "no_slot"), ] def test_staggered_caster_cannot_run() -> None: # Given: a disabled (staggered) caster; When: it proposes run (full-round) # Then: the staggered filter drops it. eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {}) eng._states[0].hp = 0 assert _take_hero_turn(eng) == [("run", False, "downgraded:staggered")] def test_staggered_caster_cannot_spell_combat() -> None: # spell_combat occupies the full-round slot: illegal while staggered. eng = _duel(_policy((Action(kind="spell_combat", target_id="foe"),)), [10, 5], {}) eng._states[0].hp = 0 assert _take_hero_turn(eng) == [ ("spell_combat", False, "downgraded:staggered") ] def test_nauseated_caster_cannot_run() -> None: # PF1e: a nauseated creature may take a single MOVE action; run is # full-round, so the nauseated filter drops it. eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {}) _add_condition(eng._states[0], "nauseated") assert _take_hero_turn(eng) == [("run", False, "downgraded:nauseated")] def test_swift_cast_is_dropped_after_immediate_used() -> None: # Given: the combatant's next swift was consumed by an immediate action # When: the policy proposes a swift-action cast plus a standard attack # Then: the swift cast is removed (downgraded:immediate_swift), attack runs. eng = _duel( _policy((_cast("Quick Bolt"), Action(kind="attack", target_id="foe"))), [10, 5, 8, 3], {"Quick Bolt": _spell("Quick Bolt", "swift")}, ) eng._states[0].immediate_used = True reasons = _take_hero_turn(eng) assert ("cast_spell", False, "downgraded:immediate_swift") in reasons assert ("attack", True, "ok") in reasons