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:
@@ -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)
|
||||
Reference in New Issue
Block a user