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,
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user