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