feat(rules): resolve one-round casts on the following turn

SRD Casting Time: a spell that takes 1 round is a full-round action
that comes into effect just before the caster's turn in the next round;
the caster then acts normally. _cast_spell now spends the slot and runs
the start-of-casting gates, then records a PendingCast (spell, target,
center, metamagic, slot level, hp at cast); delivery (touch attack, SR,
saves, effects) resolves at the start of the caster's next turn, before
the policy runs. Net hp lost in between forces the injured-while-casting
concentration check (DC 10 + damage + spell level, no Combat Casting) or
the spell fizzles; a caster knocked out of the fight loses it. Exposed
in the observation via pending_cast. Bounded model documented: net-hp
measurement instead of a per-damage-site hook; long (1 minute+) casting
stays out of scope.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-13 13:39:44 +02:00
parent 3cc0502228
commit f5dc9da0c4
5 changed files with 423 additions and 21 deletions
+46 -11
View File
@@ -461,9 +461,11 @@ Règles modélisées :
courage et est aussi retiré quand le swift est consommé par une immédiate,
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
```