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
+46 -11
View File
@@ -461,9 +461,11 @@ Règles modélisées :
courage et est aussi retiré quand le swift est consommé par une immédiate, courage et est aussi retiré quand le swift est consommé par une immédiate,
via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste
hors-tour et consomme le prochain swift) ; `full_round` → round complet ; hors-tour et consomme le prochain swift) ; `full_round` → round complet ;
`one_round` → **round complet par clamp** — approximation documentée : le `one_round` → **round complet** — SRD « Casting Time » : « A spell that
moteur ne tient pas d'incantation en cours entre les tours, le sort takes 1 round to cast is a full-round action » ; l'emplacement est dépensé
d'1 round se résout au bout du tour courant au lieu du suivant ; et les gardes de début d'incantation (AoO, concentration) résolus sur le
tour courant, mais **l'effet est différé au début du tour suivant** du
lanceur (voir « Incantation d'1 round » ci-dessous) ;
`long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non `long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non
planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")` `ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
@@ -479,6 +481,33 @@ Règles modélisées :
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps `_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
d'incantation à un round complet sans don Quickened — la signature d'incantation à un round complet sans don Quickened — la signature
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement. `Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
- Incantation d'1 round (`cast_time: one_round`) : SRD « Casting Time » —
« 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. » Sur le tour d'incantation, l'emplacement est dépensé, les
gardes de début d'incantation jouent (AoO d'incantation + jet de
concentration sur dégât, incantation défensive, agrippé), puis le sort est
consigné comme incantation en cours (`PendingCast` sur l'état du lanceur :
nom du sort, cible, point de centre, métamagie, niveau d'emplacement, pv au
moment de l'incantation) et **rien n'est appliqué** — les décisions
d'incantation (attaque de contact, RM, sauvegardes, effets) se prennent à la
résolution (« You make all pertinent decisions about a spell [...] when the
spell comes into effect »). Au début du tour suivant du lanceur, avant le
portail de début de tour et avant l'appel de politique, l'incantation en
cours se résout (journalisé `finishes casting X`), puis le lanceur agit
normalement. Interruption : toute perte nette de pv entre l'incantation et
la résolution impose le jet de concentration « blessé pendant l'incantation »
(DD `10 + dégâts + niveau du sort`, **sans** le bonus Combat Casting — même
helper `_check_concentration` que l'AoO d'incantation) ; un échec perd le
sort (`spell fizzles`, emplacement brûlé). Un lanceur mis hors de combat
avant sa résolution perd le sort (journalisé `lost (cannot act)`).
Simplifications documentées : l'interruption est mesurée en **perte nette
de pv à la résolution** et non par un crochet à chaque site de dégâts — un
soin intermédiaire peut masquer des dégâts subis ; l'incantation en cours
est exposée dans l'observation via la clé `pending_cast`
(`{spell_name, target_id, slot_level}` ou `None`). L'incantation `long`
(> 1 round) reste hors périmètre (jetée du budget, voir « Temps
d'incantation »).
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à - Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle `target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
@@ -582,7 +611,7 @@ Règles modélisées :
staggered/nauseated (ou swift consommé par une immédiate) → staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:cast_time` / `downgraded:action_economy` / `downgraded:cast_time` /
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ; `downgraded:immediate_swift` / `downgraded:surprise` ;
retard valide → `ok=True, reason="delayed"`, second retard du même round → retard valide → `ok=True, reason="delayed"`, second retard du même round →
`ok=False, reason="already_delayed"` ; préparation valide → `ok=False, reason="already_delayed"` ; préparation valide →
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
@@ -1376,7 +1405,10 @@ Règles modélisées :
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
dernier tour, pour que la policy apprenne les actions rejetées/dégradées), dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
readied ({kind, trigger} de l'action préparée ou None — préparation en readied ({kind, trigger} de l'action préparée ou None — préparation en
cours, consommée quand le déclencheur la fait tirer), cours, consommée quand le déclencheur la fait tirer), pending_cast
({spell_name, target_id, slot_level} de l'incantation d'1 round en cours
ou None — résolue au début du prochain tour du lanceur, voir « Incantation
d'1 round »),
defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale
(+4) ou du combat défensif (+2), réinitialisé au début du tour du (+4) ou du combat défensif (+2), réinitialisé au début du tour du
combattant), fight_defensively (bool — combat défensif actif : −4 aux combattant), fight_defensively (bool — combat défensif actif : −4 aux
@@ -1453,11 +1485,14 @@ la lecture mais non appliquée dans la résolution.
Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées
plutôt que bricolées) : plutôt que bricolées) :
- **Incantation `one_round` bornée à un round complet** : le moteur ne tient - **Incantation multi-round vraie (`long`) hors périmètre** : le round de
pas d'incantation en cours entre les tours ; un sort d'1 round se résout à surprise et l'incantation d'1 round sont modélisés (voir « Round de
la fin du tour courant au lieu du suivant (approximation documentée dans surprise » et « Incantation d'1 round ») ; restent hors périmètre les temps
« Temps d'incantation »). L'incantation multi-round vraie (> 1 round) est d'incantation > 1 round (3 rounds, 10 minutes, 1 heure — SRD « Casting
jetée du budget (`long`). Time » : « These actions must be consecutive and uninterrupted, or the
spell automatically fails ») : non planifiables dans un tour, ils sont
jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de
données d'objectif pour ces sorts rituels.
- **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur - **Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft** : leur
allonge nulle et la co-occupation de leurs cases (plusieurs créatures allonge nulle et la co-occupation de leurs cases (plusieurs créatures
minuscules sur une même case) ne sont pas modélisées ; seule la substitution minuscules sur une même case) ne sont pas modélisées ; seule la substitution
@@ -1579,7 +1614,7 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 1402 tests uv run pytest -q # 1408 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+162 -6
View File
@@ -578,6 +578,31 @@ class RecurringDamage:
source: str 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 @dataclass
class CombatantState: class CombatantState:
"""Mutable runtime state of one combatant on the grid.""" """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` # deals its dice at the start of this combatant's turn for `rounds_left`
# rounds, reduced by energy resistance/immunity (never DR). # rounds, reduced by energy resistance/immunity (never DR).
recurring_damage: list[RecurringDamage] = field(default_factory=list) 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]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -1470,6 +1499,15 @@ class CombatEngine:
if state.readied is not None if state.readied is not None
else 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( def _pair_snapshot(
@@ -1557,6 +1595,69 @@ class CombatEngine:
return _REASON_NO_LINE_OF_EFFECT return _REASON_NO_LINE_OF_EFFECT
return _REASON_OUT_OF_RANGE 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: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """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 # A freshly-delayed combatant is re-inserted into the round order and
@@ -1569,6 +1670,10 @@ class CombatEngine:
if not state.delayed_this_round: if not state.delayed_this_round:
self._expire_readied_action(state) self._expire_readied_action(state)
self._decrement_durations(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) gate = self._turn_gate(state)
if gate is not None: if gate is not None:
# A start-of-turn effect (bleed, confusion, cant_act) forfeited # A start-of-turn effect (bleed, confusion, cant_act) forfeited
@@ -2068,6 +2173,7 @@ class CombatEngine:
while i < len(order): while i < len(order):
state = order[i] state = order[i]
if not state.active: if not state.active:
self._forfeit_pending_cast(state)
self._process_dying(state) self._process_dying(state)
i += 1 i += 1
continue continue
@@ -5174,6 +5280,9 @@ class CombatEngine:
penalty" — the spell's own melee/ranged touch attack too. penalty" — the spell's own melee/ranged touch attack too.
Simplification: the spell may target any resolved enemy (the RAW Simplification: the spell may target any resolved enemy (the RAW
spellcasting-as-move restriction and arcane pool cost are not modeled). 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: if target is None or not target.active or spell_name is None:
return return
@@ -5448,20 +5557,66 @@ class CombatEngine:
caster, used_level, cast_defensively=cast_defensively caster, used_level, cast_defensively=cast_defensively
): ):
return None 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) # PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
# and spell resistance can still turn the spell away. A confirmed # and spell resistance can still turn the spell away. A confirmed
# touch crit doubles the spell's damage (CRB "Range — Touch"). # 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( blocked, delivery_crit = self._spell_delivery_blocked(
caster, target, spell, has_area=has_area, attack_penalty=attack_penalty caster, target, spell, has_area=has_area, attack_penalty=attack_penalty
) )
if blocked: if blocked:
return None return
self._apply_and_log_cast( self._apply_and_log_cast(
caster, target, spell, spell_name, center_pos, 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, attack_penalty=attack_penalty,
) )
return None
def _spell_cast_interrupted( def _spell_cast_interrupted(
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool 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 PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes
that kind of action; an immediate action is off-turn and consumes the 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 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: free-action cast is free. one_round occupies the full-round slot (it IS
the engine resolves it within the current turn instead of tracking a a full-round action, SRD "Casting Time") and additionally defers the
pending cast across turns (documented approximation). A long casting 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 time (more than 1 round: 3 rounds, 10 minutes, 1 hour, ...) cannot be
scheduled by the turn budget at all — the scheduled by the turn budget at all — the
engine drops such casts before the filter runs, so reaching this engine drops such casts before the filter runs, so reaching this
+4 -1
View File
@@ -8,7 +8,10 @@ distinct reason ``downgraded:cast_time``. A one-round cast is CLAMPED to the
full-round slot (documented approximation: it resolves within the turn full-round slot (documented approximation: it resolves within the turn
instead of spanning rounds). Swift casts compete with the swift bonus (and instead of spanning rounds). Swift casts compete with the swift bonus (and
are removed by a consumed swift); full-round casts are illegal for a are removed by a consumed swift); full-round casts are illegal for a
staggered creature, and run is illegal for a nauseated one. staggered creature, and run is illegal for a nauseated one. A one-round cast
occupies the full-round slot AND defers its effect to the start of the
caster's next turn (SRD "Casting Time", PendingCast — see
tests/test_one_round_cast.py).
""" """
from __future__ import annotations from __future__ import annotations
+202
View File
@@ -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)
+9 -3
View File
@@ -355,7 +355,9 @@ class TestAreaSavesPerSpellSaveType:
save-free, and secondary creatures do not always roll Reflex.""" save-free, and secondary creatures do not always roll Reflex."""
def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None: 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 — # natural 20 (first roll), the caster — second creature in the area —
# rolls its own Will and fails on natural 1 (second roll). # rolls its own Will and fails on natural 1 (second roll).
caster = _make_caster() caster = _make_caster()
@@ -365,6 +367,8 @@ class TestAreaSavesPerSpellSaveType:
) )
engine._cast_spell(c, t, "Sleep") engine._cast_spell(c, t, "Sleep")
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions) 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) assert any(ac.condition.name == "asleep" for ac in c.active_conditions)
def test_color_spray_primary_save_is_not_discarded(self) -> None: def test_color_spray_primary_save_is_not_discarded(self) -> None:
@@ -709,8 +713,9 @@ class TestSpellDamageWithDr:
class TestSummonMonster: class TestSummonMonster:
"""Given: a caster casting Summon Monster II """Given: a caster casting Summon Monster II (one-round cast)
When: _cast_spell is called 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.""" Then: a celestial wolf is summoned adjacent to the caster."""
def test_summon_creature(self) -> None: 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]) 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) hp_before = len(engine._states)
engine._cast_spell(c, t, "Summon Monster II") engine._cast_spell(c, t, "Summon Monster II")
engine._resolve_pending_cast(c)
# Caster + target + summoned = 3 combatants # Caster + target + summoned = 3 combatants
assert len(engine._states) == hp_before + 1 assert len(engine._states) == hp_before + 1
# Find the summoned creature # Find the summoned creature