"""S3b conformance: true one-round casting (SRD "Casting Time"). "A spell that takes 1 round to cast is a full-round action. It comes into effect just before the beginning of your turn in the round after you began casting the spell. You then act normally after the spell is completed." "When you begin a spell that takes 1 round or longer to cast, you must continue the concentration from the current round to just before your turn in the next round (at least). If you lose concentration before the casting is complete, you lose the spell." (CRB "Concentration" — Injury: DC 10 + damage taken + spell level, no Combat Casting bonus.) """ from __future__ import annotations from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import ConditionEffect, SpellSpec, SummonEffect from tests.test_combat import make_combatant, make_grid from tests.test_spells import _make_caster, _make_engine, _make_target _WAIT: tuple[Action, ...] = (Action(kind="wait"),) _CAST_FOG = Action(kind="cast_spell", target_id="target", spell_name="Fog") _STRIKE: tuple[Action, ...] = ( Action(kind="move", target_id="caster"), Action(kind="attack", target_id="caster"), ) def _fog() -> SpellSpec: return SpellSpec( name="Fog", level=1, school="conjuration", range="close", save="none", cast_time="one_round", effects=(ConditionEffect(condition_name="asleep", duration_rounds=4),), ) def _duel( queue: list[int], caster_actions: dict[int, tuple[Action, ...]], enemy_actions: dict[int, tuple[Action, ...]] | None = None, *, caster_hp: int | None = None, with_ally: bool = False, ) -> tuple[CombatEngine, CombatantState, CombatantState]: """Caster (init 15+2) vs enemy "target" (init 10) 2 cells apart, 2 rounds. Scripted rolls run in queue order; the enemy waits unless given actions. With ``with_ally`` a waiting ally keeps the battle alive if the caster falls (so a dying caster's initiative slot is still reached). """ caster = _make_caster().model_copy(update={"spells": ["Fog"]}) foe = _make_target() c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max) t = CombatantState(combatant=foe, side="monsters", pos=(0, 2), hp=foe.hp_max) states = [c, t] if with_ally: ally = make_combatant("ally") states.insert(1, CombatantState( combatant=ally, side="players", pos=(0, 5), hp=ally.hp_max, )) if caster_hp is not None: c.hp = caster_hp def policy(e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: if s.combatant.id == "caster": return caster_actions.get(e._current_round, _WAIT) if s.combatant.id == "target": return enemy_actions.get(e._current_round, _WAIT) if enemy_actions else _WAIT return _WAIT engine = CombatEngine( ScriptedRng(queue), make_grid(), states, policy=policy, round_cap=2, spell_registry={"Fog": _fog()}, ) return engine, c, t def _is_asleep(t: CombatantState) -> bool: return any(ac.condition.name == "asleep" for ac in t.active_conditions) def test_one_round_cast_resolves_on_the_next_turn_not_the_same_turn() -> None: # Given: a caster beginning a one-round cast on round 1 # When: the battle runs two rounds # Then: the effect lands at the START of round 2 (before the caster acts) # and the caster then acts normally that same turn. # Rolls: initiative x2, round-2 attack d20=5 (5+2+4 helpless vs AC 10 # hit), damage d4=2. engine, _c, t = _duel( [15, 10, 5, 2], {1: (_CAST_FOG,), 2: (Action(kind="move", target_id="target"), Action(kind="attack", target_id="target"))}, ) transcript = list(engine.run().transcript) begins = next(i for i, ln in enumerate(transcript) if "begins casting Fog" in ln) finishes = next(i for i, ln in enumerate(transcript) if "finishes casting Fog" in ln) delivered = next(i for i, ln in enumerate(transcript) if "cast Fog vs" in ln) attack = next(i for i, ln in enumerate(transcript) if "dagger vs target" in ln) assert transcript[begins].startswith("round 1 ") assert transcript[finishes].startswith("round 2 ") assert begins < finishes < delivered < attack assert _is_asleep(t) def test_damage_before_resolution_forces_a_concentration_check() -> None: # Given: the enemy hits the caster between the cast and its resolution # When: the caster fails the injured-while-casting check (DC 14) # Then: the spell is lost with no effect. # Rolls: initiative x2, attack d20=10 (13 vs AC 12 hit), damage 3, # concentration d20=1 (1+5+4=10 < 14). engine, _c, t = _duel([15, 10, 10, 3, 1], {1: (_CAST_FOG,)}, {1: _STRIKE}) transcript = list(engine.run().transcript) assert any("spell fizzles" in ln and "DC 14" in ln for ln in transcript) assert not any("finishes casting Fog" in ln for ln in transcript) assert not _is_asleep(t) def test_passed_concentration_still_delivers_the_spell() -> None: # Given: the same injury # When: the caster passes the check (20+5+4=29 >= 14) # Then: the spell resolves at the start of the next turn. engine, _c, t = _duel([15, 10, 10, 3, 20], {1: (_CAST_FOG,)}, {1: _STRIKE}) transcript = list(engine.run().transcript) assert any("finishes casting Fog" in ln for ln in transcript) assert _is_asleep(t) def test_dead_caster_loses_the_pending_cast() -> None: # Given: a caster at 4 hp beginning a one-round cast, with an ally who # keeps the battle alive after the caster falls # When: the enemy's blow (6 damage) drops it below 0 before resolution # Then: the spell is lost at the caster's initiative slot. # Rolls: initiative x3, attack d20=10 (hit), damage 6, then the dying # caster's failed stabilization check d20=1 (1+1-2 < 10). engine, _c, t = _duel( [15, 15, 10, 10, 6, 1], {1: (_CAST_FOG,)}, {1: _STRIKE}, caster_hp=4, with_ally=True, ) transcript = list(engine.run().transcript) assert any("one-round cast of Fog lost (cannot act)" in ln for ln in transcript) assert not _is_asleep(t) def test_slot_spent_at_cast_time_delivery_deferred() -> None: # Given: a caster with level-1 slots # When: _cast_spell records the one-round cast # Then: the slot is gone, the effect is not applied yet, and the # observation exposes the pending cast until resolution. engine, c, t = _make_engine( _make_caster().model_copy(update={"spells": ["Fog"]}), _make_target(), [], spell_registry={"Fog": _fog()}, ) slots_before = c.spell_slots_left[1] engine._cast_spell(c, t, "Fog") assert c.spell_slots_left[1] == slots_before - 1 assert c.pending_cast is not None assert c.pending_cast.spell_name == "Fog" assert not _is_asleep(t) pending = engine._build_observation(t).combatants[0]["pending_cast"] assert isinstance(pending, dict) assert pending["spell_name"] == "Fog" engine._resolve_pending_cast(c) assert c.pending_cast is None assert _is_asleep(t) assert engine._build_observation(t).combatants[0]["pending_cast"] is None def test_one_round_summon_arrives_on_the_following_turn() -> None: # Given: Summon Monster I (one_round, SRD) — the creature must not appear # on the casting turn. # When: the pending cast resolves at the start of the next turn # Then: the summoned creature joins the battle. summon = SpellSpec( name="Summon Monster I", level=1, school="conjuration", range="close", cast_time="one_round", effects=( SummonEffect( creature="data/monsters/celestial_wolf.json", duration_rounds=2 ), ), ) engine, c, t = _make_engine( _make_caster(), _make_target(), [], spell_registry={"Summon Monster I": summon}, ) engine._cast_spell(c, t, "Summon Monster I") assert not any(s.combatant.id.startswith("celestial") for s in engine.states) engine._resolve_pending_cast(c) assert any(s.combatant.id.startswith("celestial") for s in engine.states)