fix(rules): enforce the action-economy budget on policy output
Apply the PF1e per-turn action budget (CRB Actions in Combat: one standard + one move, or one full-round in place of both, plus one swift) unconditionally to every sequence a policy returns, not only the immediate/staggered/nauseated specializations. - _action_economy_filter: greedy in-order walk keeping the earliest legal prefix-subsequence; a full-round action is only kept while both the standard and move slots are still free. - Exhaustive kind->slot classification (standard / move / swift / full_round / free-conditional) over the Action.kind Literal, documented and locked by tests. - Surplus actions dropped and reported as ActionResult(ok=False, reason="downgraded:action_economy") via a multiset drop diff (reports duplicate-kind surplus the set-based _dropped_kinds cannot). - Composition order: general budget first, then the existing immediate/staggered/nauseated filters (unchanged). - default_policy untouched and its turns verified legal by the validator; golden transcripts byte-identical.
This commit is contained in:
@@ -311,6 +311,45 @@ Règles modélisées :
|
||||
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
|
||||
(hors-tour, consomment le prochain swift) sont modélisées via une politique
|
||||
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
||||
**Application du budget (conformance X4)** : le moteur applique désormais le
|
||||
budget d'action PF1e (CRB « Actions in Combat ») à TOUTE séquence renvoyée par
|
||||
une politique, y compris une future politique LLM, et non plus seulement aux
|
||||
cas immédiat/accablé/nausée. Chaque kind d'action est classé dans un seul
|
||||
compartiment du budget — standard, mouvement, swift, round complet, ou
|
||||
libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget
|
||||
(≤ 1 standard **ou** 1 round complet, ≤ 1 mouvement, ≤ 1 swift ; un round
|
||||
complet consacre à la fois le standard ET le mouvement). La dégradation est
|
||||
**déterministe et stable en ordre** : parcours glouton dans l'ordre, chaque
|
||||
action est gardée tant que son compartiment est libre ; le résultat est donc le
|
||||
plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et
|
||||
rapportée comme `ActionResult(kind=…, ok=False, reason="downgraded:action_economy")`
|
||||
(visible dans `last_action_results` et le snapshot `last_turn_results`). Exemples :
|
||||
`(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ;
|
||||
`(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` →
|
||||
`(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`,
|
||||
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||
`repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une
|
||||
mécanique de mouvement posant `moved_this_turn` et déjà exclu de
|
||||
`_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`,
|
||||
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ;
|
||||
**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.
|
||||
- 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
|
||||
@@ -395,8 +434,11 @@ Règles modélisées :
|
||||
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 les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
||||
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` ;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user