Files
pf1e-simulator/tests/test_one_round_cast.py
T
ctan f5dc9da0c4 feat(rules): resolve one-round casts on the following turn
SRD Casting Time: a spell that takes 1 round is a full-round action
that comes into effect just before the caster's turn in the next round;
the caster then acts normally. _cast_spell now spends the slot and runs
the start-of-casting gates, then records a PendingCast (spell, target,
center, metamagic, slot level, hp at cast); delivery (touch attack, SR,
saves, effects) resolves at the start of the caster's next turn, before
the policy runs. Net hp lost in between forces the injured-while-casting
concentration check (DC 10 + damage + spell level, no Combat Casting) or
the spell fizzles; a caster knocked out of the fight loses it. Exposed
in the observation via pending_cast. Bounded model documented: net-hp
measurement instead of a per-damage-site hook; long (1 minute+) casting
stays out of scope.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 13:39:44 +02:00

203 lines
8.2 KiB
Python

"""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)