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
+162 -6
View File
@@ -578,6 +578,31 @@ class RecurringDamage:
source: str
@dataclass(frozen=True)
class PendingCast:
"""A one-round cast begun this turn, to be resolved next turn.
PF1e (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." Everything the delivery still
needs is frozen at the start of the casting (the slot is already
spent): ``slot_level`` carries the metamagic-inflated level (B7) the
save DC uses, ``hp_at_cast`` is the caster's hp when the casting began
— the net hp lost before resolution is the interruption damage that
forces the concentration check (CRB "Concentration": injured while
casting, DC 10 + damage + spell level).
"""
spell_name: str
target_id: str | None
center_pos: tuple[int, int] | None
metamagic: tuple[str, ...]
slot_level: int
cast_defensively: bool
hp_at_cast: int
@dataclass
class CombatantState:
"""Mutable runtime state of one combatant on the grid."""
@@ -656,6 +681,10 @@ class CombatantState:
# deals its dice at the start of this combatant's turn for `rounds_left`
# rounds, reduced by energy resistance/immunity (never DR).
recurring_damage: list[RecurringDamage] = field(default_factory=list)
# PF1e one-round casting (SRD "Casting Time", not in the frozen data
# model): the spell begun on this combatant's turn, resolved just before
# its NEXT turn begins; None when no casting is in progress.
pending_cast: PendingCast | None = None
def usable_attacks(self) -> list[AttackSpec]:
return [
@@ -1470,6 +1499,15 @@ class CombatEngine:
if state.readied is not None
else None
),
"pending_cast": (
None
if state.pending_cast is None
else {
"spell_name": state.pending_cast.spell_name,
"target_id": state.pending_cast.target_id,
"slot_level": state.pending_cast.slot_level,
}
),
}
def _pair_snapshot(
@@ -1557,6 +1595,69 @@ class CombatEngine:
return _REASON_NO_LINE_OF_EFFECT
return _REASON_OUT_OF_RANGE
def _resolve_pending_cast(self, caster: CombatantState) -> None:
"""PF1e (SRD "Casting Time"): a one-round cast "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." The
caster is busy casting through the intervening round: net hp lost
between the start of the casting and this moment forces the
injured-while-casting concentration check (CRB "Concentration":
DC 10 + damage + spell level, no Combat Casting bonus) or the spell
is lost.
Bounded model (documented): interruption is measured as NET hp loss
at resolution instead of a hook at every damage site, so healing in
between can mask damage taken.
"""
pending = caster.pending_cast
if pending is None:
return
caster.pending_cast = None
spell = self._spell_registry.get(pending.spell_name)
if spell is None:
return
damage = max(0, pending.hp_at_cast - caster.hp)
if damage > 0 and not self._check_concentration(
caster, pending.slot_level, damage
):
dc = self._concentration_dc(caster, pending.slot_level, damage)
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"one-round cast of {pending.spell_name} interrupted: "
f"concentration check failed (DC {dc}), spell fizzles"
)
return
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"finishes casting {pending.spell_name}"
)
target = (
next(
(s for s in self._states if s.combatant.id == pending.target_id),
None,
)
if pending.target_id is not None
else None
)
self._deliver_cast(
caster, target, spell, pending.spell_name, pending.center_pos,
metamagic=pending.metamagic, slot_level=pending.slot_level,
)
def _forfeit_pending_cast(self, caster: CombatantState) -> None:
"""PF1e (SRD "Casting Time"): "you must continue the concentration
from the current round to just before your turn in the next round
[...] If you lose concentration before the casting is complete, you
lose the spell." A caster that reaches its initiative slot unable to
act (dead or dying) never gets to resolve the casting."""
pending = caster.pending_cast
if pending is None:
return
caster.pending_cast = None
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"one-round cast of {pending.spell_name} lost (cannot act)"
)
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
# A freshly-delayed combatant is re-inserted into the round order and
@@ -1569,6 +1670,10 @@ class CombatEngine:
if not state.delayed_this_round:
self._expire_readied_action(state)
self._decrement_durations(state)
# SRD "Casting Time": the one-round cast "comes into effect just
# before the beginning of your turn" — resolved here, before the
# start-of-turn gate, then the turn proceeds normally.
self._resolve_pending_cast(state)
gate = self._turn_gate(state)
if gate is not None:
# A start-of-turn effect (bleed, confusion, cant_act) forfeited
@@ -2068,6 +2173,7 @@ class CombatEngine:
while i < len(order):
state = order[i]
if not state.active:
self._forfeit_pending_cast(state)
self._process_dying(state)
i += 1
continue
@@ -5174,6 +5280,9 @@ class CombatEngine:
penalty" — the spell's own melee/ranged touch attack too.
Simplification: the spell may target any resolved enemy (the RAW
spellcasting-as-move restriction and arcane pool cost are not modeled).
Spell Combat requires a casting time of 1 standard action; the policy
only proposes such spells, and a one-round spell would simply resolve
on the caster's next turn via ``PendingCast``.
"""
if target is None or not target.active or spell_name is None:
return
@@ -5448,20 +5557,66 @@ class CombatEngine:
caster, used_level, cast_defensively=cast_defensively
):
return None
if spell.cast_time == "one_round":
# PF1e (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." The slot is spent and the start-of-casting gates have
# run; delivery (touch attack, SR, saves, effects — "You make all
# pertinent decisions about a spell [...] when the spell comes
# into effect") is deferred to _resolve_pending_cast.
caster.pending_cast = PendingCast(
spell_name=spell_name,
target_id=target.combatant.id if target is not None else None,
center_pos=center_pos,
metamagic=applied,
slot_level=used_level,
cast_defensively=cast_defensively,
hp_at_cast=caster.hp,
)
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"begins casting {spell_name} (1 round)"
)
return None
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
# and spell resistance can still turn the spell away. A confirmed
# touch crit doubles the spell's damage (CRB "Range — Touch").
self._deliver_cast(
caster, target, spell, spell_name, center_pos,
metamagic=applied, slot_level=used_level,
attack_penalty=attack_penalty,
)
return None
def _deliver_cast(
self,
caster: CombatantState,
target: CombatantState | None,
spell: SpellSpec,
spell_name: str,
center_pos: tuple[int, int] | None,
*,
metamagic: tuple[str, ...],
slot_level: int,
attack_penalty: int = 0,
) -> None:
"""Post-gate delivery: touch attack, spell resistance, then effects.
Shared by the immediate cast path and the resolution of a pending
one-round cast (SRD "Casting Time").
"""
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
blocked, delivery_crit = self._spell_delivery_blocked(
caster, target, spell, has_area=has_area, attack_penalty=attack_penalty
)
if blocked:
return None
return
self._apply_and_log_cast(
caster, target, spell, spell_name, center_pos,
metamagic=applied, slot_level=used_level, spell_crit=delivery_crit,
metamagic=metamagic, slot_level=slot_level, spell_crit=delivery_crit,
attack_penalty=attack_penalty,
)
return None
def _spell_cast_interrupted(
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool
@@ -7151,9 +7306,10 @@ def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes
that kind of action; an immediate action is off-turn and consumes the
next swift (handled via immediate_used, so it is budget-free here); a
free-action cast is free. one_round is CLAMPED to the full-round slot:
the engine resolves it within the current turn instead of tracking a
pending cast across turns (documented approximation). A long casting
free-action cast is free. one_round occupies the full-round slot (it IS
a full-round action, SRD "Casting Time") and additionally defers the
spell's effect to the start of the caster's next turn via
``PendingCast``. A long casting
time (more than 1 round: 3 rounds, 10 minutes, 1 hour, ...) cannot be
scheduled by the turn budget at all — the
engine drops such casts before the filter runs, so reaching this