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
+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