feat(spells): honor spell cast time in the action budget

SpellSpec gains a cast_time field (seven budget codes; the JSON loader
also accepts RAW phrasings like "1 round" or "1 minute"). The pure
budget filters take a CastTimeResolver so a cast_spell occupies the slot
its casting time maps to: standard->standard, swift->swift (competing
with deed/inspire courage and dropped by a consumed immediate),
immediate/free->free, full_round and one_round->full_round (one_round
clamped: resolved end-of-turn, documented approximation), long->dropped
before the budget filter with the new downgraded:cast_time reason. The
engine binds its registry lookup via _cast_time_of (unknown names fall
back to standard, keeping transcripts byte-identical);
_choose_spell_action never selects long casts; the staggered filter now
drops full-round casts and spell_combat via the effective slot
(_FULL_ROUND_KINDS becomes dead and is deleted). Summon Monster I/II are
the only data spells marked 1 round. Metamagic full-round casting for
spontaneous casters is out of scope (future hook documented in
_cast_time_of).

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-11 14:09:58 +02:00
parent 0997c8d1b5
commit c5426ffe34
8 changed files with 525 additions and 72 deletions
+60 -25
View File
@@ -375,27 +375,57 @@ Règles modélisées :
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
voir « Temps d'incantation » plus bas ; standard par défaut) ;
**mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon` ; **swift** = `swift`, `deed`,
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
en posant `moved_this_turn` et `running`) ;
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
basculement Power Attack). Ordre de composition : le budget général est
appliqué EN PREMIER, puis les filtres spécialisés (immédiat/accablé/nausée)
s'exercent sur le reste déjà légal (leur sortie est une action unique, donc
jamais re-filtrée par le budget). L'ordre de dégradation est celui de la
politique : la première action légale d'un compartiment gagne, les suivantes
sont excédentaires.
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
basculement Power Attack). Ordre de composition : le pré-filtre des temps
d'incantation « longs » est appliqué EN PREMIER (voir « Temps d'incantation »
ci-dessous), puis le budget général, puis les filtres spécialisés
(immédiat/accablé/nausée) s'exercent sur le reste déjà légal (leur sortie est
une action unique, donc jamais re-filtrée par le budget). L'ordre de
dégradation est celui de la politique : la première action légale d'un
compartiment gagne, les suivantes sont excédentaires.
- Temps d'incantation (`SpellSpec.cast_time`, conformance économie d'action) :
chaque sort porte un temps d'incantation normalisé en codes de budget —
`standard`, `swift`, `immediate`, `free`, `full_round`, `one_round`, `long`
(défaut `standard`). Le chargeur JSON accepte aussi les formulations RAW :
`"1 round"`/`"one round"` → `one_round`, `"1 full-round action"`/
`"full-round action"`/`"full-round"` → `full_round`, `"1 minute"`/
`"10 minutes"`/`"1 hour"`/`"long"` → `long`, `"standard action"`,
`"swift action"`, `"immediate action"`, `"free action"` ; une valeur
inconnue est une erreur de données (`ValueError`). Mapping vers le budget :
`standard` → standard ; `swift` → swift (il concurrence donc deed/inspire
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 ;
`long` (≥ 1 minute) → **jeté avant le filtre de budget**, rapporté comme
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
jamais un sort à temps long). Le moteur résout le code via sa registry
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
filtres purs sous forme de résolveur `CastTimeResolver`. Seuls
`Summon Monster I/II` déclarent `"1 round"` dans `data/spells/` ; les autres
sorts exotiques (Bladed Dash, Silence…) restent au défaut `standard` — hors
du périmètre de ce paquet. Hors du périmètre (hook futur, commenté dans
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
d'incantation à un round complet sans don Quickened — le signature
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
- 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
@@ -477,14 +507,16 @@ Règles modélisées :
- Retour d'exécution des actions : chaque action produit un `ActionResult`
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
par le budget général d'action (X4 — action excédentaire retirée de la
séquence de la politique) ou par les filtres staggered/nauseated (ou swift
consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ;
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
par le budget général d'action (X4 — action excédentaire retirée de la
séquence de la politique), par le pré-filtre des temps d'incantation longs
(sort `long` jamais planifiable dans un tour) ou par les filtres
staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:cast_time` /
`downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ;
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,
@@ -1090,10 +1122,13 @@ la lecture mais non appliquée dans la résolution.
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
sauvegarde, DC de base, temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
descripteurs de sauvegarde
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur