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