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>
This commit is contained in:
2026-09-13 13:39:44 +02:00
parent 3cc0502228
commit f5dc9da0c4
5 changed files with 423 additions and 21 deletions
+4 -1
View File
@@ -8,7 +8,10 @@ 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.
staggered creature, and run is illegal for a nauseated one. A one-round cast
occupies the full-round slot AND defers its effect to the start of the
caster's next turn (SRD "Casting Time", PendingCast — see
tests/test_one_round_cast.py).
"""
from __future__ import annotations
+202
View File
@@ -0,0 +1,202 @@
"""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)
+9 -3
View File
@@ -355,7 +355,9 @@ class TestAreaSavesPerSpellSaveType:
save-free, and secondary creatures do not always roll Reflex."""
def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None:
# Sleep (Will negates, save_for_half=False): primary target saves on
# Sleep (one-round cast, Will negates, save_for_half=False): casting
# records the pending cast; resolution (start of the caster's next
# turn, no damage taken) delivers it — primary target saves on
# natural 20 (first roll), the caster — second creature in the area —
# rolls its own Will and fails on natural 1 (second roll).
caster = _make_caster()
@@ -365,6 +367,8 @@ class TestAreaSavesPerSpellSaveType:
)
engine._cast_spell(c, t, "Sleep")
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
engine._resolve_pending_cast(c)
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
assert any(ac.condition.name == "asleep" for ac in c.active_conditions)
def test_color_spray_primary_save_is_not_discarded(self) -> None:
@@ -709,8 +713,9 @@ class TestSpellDamageWithDr:
class TestSummonMonster:
"""Given: a caster casting Summon Monster II
When: _cast_spell is called
"""Given: a caster casting Summon Monster II (one-round cast)
When: _cast_spell is called and the pending cast resolves on the
caster's next turn (SRD "Casting Time")
Then: a celestial wolf is summoned adjacent to the caster."""
def test_summon_creature(self) -> None:
@@ -719,6 +724,7 @@ class TestSummonMonster:
engine, c, t = _make_engine(caster, target, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = len(engine._states)
engine._cast_spell(c, t, "Summon Monster II")
engine._resolve_pending_cast(c)
# Caster + target + summoned = 3 combatants
assert len(engine._states) == hp_before + 1
# Find the summoned creature