f5dc9da0c4
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>
203 lines
8.2 KiB
Python
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)
|