diff --git a/README.md b/README.md index d08122b..52612a3 100644 --- a/README.md +++ b/README.md @@ -461,9 +461,11 @@ Règles modélisées : 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 hors-tour et consomme le prochain swift) ; `full_round` → round complet ; - `one_round` → **round complet par clamp** — approximation documentée : le - moteur ne tient pas d'incantation en cours entre les tours, le sort - d'1 round se résout au bout du tour courant au lieu du suivant ; + `one_round` → **round complet** — SRD « Casting Time » : « A spell that + takes 1 round to cast is a full-round action » ; l'emplacement est dépensé + 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 planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme `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 d'incantation à un round complet sans don Quickened — la signature `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 à 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 @@ -582,7 +611,7 @@ Règles modélisées : staggered/nauseated (ou swift consommé par une immédiate) → `downgraded:action_economy` / `downgraded:cast_time` / `downgraded:staggered` / `downgraded:nauseated` / - `downgraded:immediate_swift` ; + `downgraded:immediate_swift` / `downgraded:surprise` ; retard valide → `ok=True, reason="delayed"`, second retard du même round → `ok=False, reason="already_delayed"` ; préparation valide → `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 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 - 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 (+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 @@ -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 plutôt que bricolées) : -- **Incantation `one_round` bornée à un round complet** : le moteur ne tient - pas d'incantation en cours entre les tours ; un sort d'1 round se résout à - la fin du tour courant au lieu du suivant (approximation documentée dans - « Temps d'incantation »). L'incantation multi-round vraie (> 1 round) est - jetée du budget (`long`). +- **Incantation multi-round vraie (`long`) hors périmètre** : le round de + surprise et l'incantation d'1 round sont modélisés (voir « Round de + surprise » et « Incantation d'1 round ») ; restent hors périmètre les temps + d'incantation > 1 round (3 rounds, 10 minutes, 1 heure — SRD « Casting + 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 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 @@ -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) : ```bash -uv run pytest -q # 1402 tests +uv run pytest -q # 1408 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 5fd3b8a..783928f 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_conformance_cast_time.py b/tests/test_conformance_cast_time.py index dbfab73..d7f8bf5 100644 --- a/tests/test_conformance_cast_time.py +++ b/tests/test_conformance_cast_time.py @@ -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 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 -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 diff --git a/tests/test_one_round_cast.py b/tests/test_one_round_cast.py new file mode 100644 index 0000000..39e7fbf --- /dev/null +++ b/tests/test_one_round_cast.py @@ -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) diff --git a/tests/test_spells.py b/tests/test_spells.py index 9e10fd7..39e30e5 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -355,7 +355,9 @@ class TestAreaSavesPerSpellSaveType: save-free, and secondary creatures do not always roll Reflex.""" 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 — # rolls its own Will and fails on natural 1 (second roll). caster = _make_caster() @@ -365,6 +367,8 @@ class TestAreaSavesPerSpellSaveType: ) engine._cast_spell(c, t, "Sleep") 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) def test_color_spray_primary_save_is_not_discarded(self) -> None: @@ -709,8 +713,9 @@ class TestSpellDamageWithDr: class TestSummonMonster: - """Given: a caster casting Summon Monster II - When: _cast_spell is called + """Given: a caster casting Summon Monster II (one-round cast) + 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.""" 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]) hp_before = len(engine._states) engine._cast_spell(c, t, "Summon Monster II") + engine._resolve_pending_cast(c) # Caster + target + summoned = 3 combatants assert len(engine._states) == hp_before + 1 # Find the summoned creature