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