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)`.
|
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
|
||||||
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
|
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
|
||||||
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
|
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
|
||||||
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
|
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
||||||
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
|
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
|
||||||
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
|
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
|
||||||
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
|
voir « Temps d'incantation » plus bas ; standard par défaut) ;
|
||||||
|
**mouvement** = `move`, `stand_up`,
|
||||||
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
`retrieve_weapon` (sortir une arme est une action de mouvement),
|
||||||
`repair_weapon` ; **swift** = `swift`, `deed`,
|
`repair_weapon` ; **swift** = `swift`, `deed`,
|
||||||
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||||
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
|
`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
|
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
|
||||||
en posant `moved_this_turn` et `running`) ;
|
en posant `moved_this_turn` et `running`) ;
|
||||||
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
|
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
|
||||||
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
|
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
|
||||||
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
|
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
|
||||||
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
|
`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
|
(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
|
basculement Power Attack). Ordre de composition : le pré-filtre des temps
|
||||||
appliqué EN PREMIER, puis les filtres spécialisés (immédiat/accablé/nausée)
|
d'incantation « longs » est appliqué EN PREMIER (voir « Temps d'incantation »
|
||||||
s'exercent sur le reste déjà légal (leur sortie est une action unique, donc
|
ci-dessous), puis le budget général, puis les filtres spécialisés
|
||||||
jamais re-filtrée par le budget). L'ordre de dégradation est celui de la
|
(immédiat/accablé/nausée) s'exercent sur le reste déjà légal (leur sortie est
|
||||||
politique : la première action légale d'un compartiment gagne, les suivantes
|
une action unique, donc jamais re-filtrée par le budget). L'ordre de
|
||||||
sont excédentaires.
|
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 à
|
- 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
|
||||||
@@ -477,14 +507,16 @@ Règles modélisées :
|
|||||||
- Retour d'exécution des actions : chaque action produit un `ActionResult`
|
- Retour d'exécution des actions : chaque action produit un `ActionResult`
|
||||||
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
|
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
|
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'être des no-op silencieux : cible absente → `missing_target` ; kinds
|
||||||
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
|
||||||
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
|
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
|
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
|
séquence de la politique), par le pré-filtre des temps d'incantation longs
|
||||||
consommé par une immédiate) →
|
(sort `long` jamais planifiable dans un tour) ou par les filtres
|
||||||
`downgraded:action_economy` / `downgraded:staggered` / `downgraded:nauseated` /
|
staggered/nauseated (ou swift consommé par une immédiate) →
|
||||||
`downgraded:immediate_swift` ;
|
`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 →
|
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,
|
||||||
@@ -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
|
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
||||||
`AbilitySpec` réels ou des « no-op » tracés.
|
`AbilitySpec` réels ou des « no-op » tracés.
|
||||||
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
|
- `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,
|
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
||||||
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
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,
|
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
|
||||||
conditions de peur, écoles enchantement/divination) sont dérivés des effets
|
conditions de peur, écoles enchantement/divination) sont dérivés des effets
|
||||||
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
|
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"cast_time": "1 round",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "summon",
|
"type": "summon",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"cast_time": "1 round",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "summon",
|
"type": "summon",
|
||||||
|
|||||||
+170
-46
@@ -4,10 +4,13 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
- Action economy: a normal turn grants one standard action + one move action
|
- Action economy: a normal turn grants one standard action + one move action
|
||||||
(or one full-round action), plus swift/free/immediate. The policy returns an
|
(or one full-round action), plus swift/free/immediate. The policy returns an
|
||||||
ordered sequence of `Action` per turn; the engine enforces the budget on it
|
ordered sequence of `Action` per turn; the engine enforces the budget on it
|
||||||
(X4, `_action_economy_filter`): surplus standards/moves/swifts and a
|
(X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's
|
||||||
full-round action conflicting with an already-spent standard/move are
|
casting time (long casts are dropped before the budget with
|
||||||
|
`downgraded:cast_time`), surplus standards/moves/swifts and a full-round
|
||||||
|
action conflicting with an already-spent standard/move are
|
||||||
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
||||||
`downgraded:action_economy`; the condition-specific filters then apply as
|
`downgraded:action_economy`; a move action may convert a still-free standard
|
||||||
|
slot; the condition-specific filters then apply as
|
||||||
before. The engine executes the remaining actions in order,
|
before. The engine executes the remaining actions in order,
|
||||||
stopping early if the actor drops or one side is wiped. `default_policy`
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||||
returns `(full_attack,)` when already in range, `(charge,)` when a
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||||
@@ -114,6 +117,7 @@ from pf1e_simulator.models import PREDEFINED_SKILLS
|
|||||||
from pf1e_simulator.spells import (
|
from pf1e_simulator.spells import (
|
||||||
AreaEffect,
|
AreaEffect,
|
||||||
BuffEffect,
|
BuffEffect,
|
||||||
|
CastTime,
|
||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
@@ -782,13 +786,17 @@ class Action:
|
|||||||
# no_weapon (aid another without a usable melee weapon) and
|
# no_weapon (aid another without a usable melee weapon) and
|
||||||
# unreachable_destination (run to an unhonourable destination). X4 adds
|
# unreachable_destination (run to an unhonourable destination). X4 adds
|
||||||
# downgraded:action_economy for actions the general per-turn budget filter
|
# downgraded:action_economy for actions the general per-turn budget filter
|
||||||
# drops from the policy sequence (second standard, second move, second
|
# drops from the policy sequence (second standard, second move when no
|
||||||
# swift, full-round conflicting with standard+move).
|
# standard is left to convert, second swift, full-round conflicting with
|
||||||
|
# standard+move). The cast-time package adds downgraded:cast_time for
|
||||||
|
# cast_spell actions whose spell has a long casting time (1+ minutes): the
|
||||||
|
# engine cannot schedule them within a turn, so they are dropped before the
|
||||||
|
# budget filter runs.
|
||||||
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||||
"downgraded:staggered", "downgraded:nauseated",
|
"downgraded:staggered", "downgraded:nauseated",
|
||||||
"downgraded:action_economy",
|
"downgraded:action_economy", "downgraded:cast_time",
|
||||||
"delayed", "already_delayed", "ready",
|
"delayed", "already_delayed", "ready",
|
||||||
"not_prone", "already_prone", "unreachable_destination",
|
"not_prone", "already_prone", "unreachable_destination",
|
||||||
"weapon_unavailable", "invalid_destination",
|
"weapon_unavailable", "invalid_destination",
|
||||||
@@ -800,6 +808,7 @@ _REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
|||||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||||
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||||
|
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
||||||
_REASON_DELAYED = "delayed"
|
_REASON_DELAYED = "delayed"
|
||||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||||
_REASON_READY = "ready"
|
_REASON_READY = "ready"
|
||||||
@@ -1436,6 +1445,20 @@ class CombatEngine:
|
|||||||
state.last_action_results = results
|
state.last_action_results = results
|
||||||
return self._winner_side() is not None
|
return self._winner_side() is not None
|
||||||
|
|
||||||
|
def _cast_time_of(self, action: Action) -> CastTime:
|
||||||
|
"""Resolve a cast_spell action's casting time from the registry.
|
||||||
|
|
||||||
|
Unknown spell names (and non-cast actions) default to "standard".
|
||||||
|
Future hook (out of scope here): for a spontaneous caster this
|
||||||
|
closure would read state.combatant.spontaneous_caster and
|
||||||
|
action.metamagic — a spontaneous cast with a non-empty metamagic
|
||||||
|
feat and no Quickened Spell becomes full_round (unless the spell is
|
||||||
|
already one_round or long). The Callable[[Action], CastTime]
|
||||||
|
resolver signature already accommodates the state-aware variant.
|
||||||
|
"""
|
||||||
|
spell = self._spell_registry.get(action.spell_name or "")
|
||||||
|
return spell.cast_time if spell is not None else "standard"
|
||||||
|
|
||||||
def _filter_turn_actions(
|
def _filter_turn_actions(
|
||||||
self,
|
self,
|
||||||
state: CombatantState,
|
state: CombatantState,
|
||||||
@@ -1444,17 +1467,32 @@ class CombatEngine:
|
|||||||
) -> tuple[Action, ...]:
|
) -> tuple[Action, ...]:
|
||||||
"""Downgrade the policy's action sequence to the legal action economy.
|
"""Downgrade the policy's action sequence to the legal action economy.
|
||||||
|
|
||||||
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
Composition order: a cast-time pre-pass first drops every cast_spell
|
||||||
Combat": one standard + one move, or one full-round, plus one swift)
|
whose spell has a long casting time (1+ minutes — the engine cannot
|
||||||
is enforced first on every sequence; the condition-specific
|
schedule it within a turn; reported as downgraded:cast_time). Then
|
||||||
specializations then run on the budget-legal remainder (their output
|
the general per-turn budget (CRB, "Actions in Combat": one standard
|
||||||
is a single action, so the budget never re-drops it). Swift consumed
|
+ one move, or one full-round, plus one swift; a move action may be
|
||||||
by an immediate action; PF1e (CRB, Disabled): a creature at exactly
|
taken instead of the standard) is enforced on every sequence, with
|
||||||
0 hp is staggered — one move OR one standard action per round, no
|
each cast_spell occupying the slot of its resolved casting time. The
|
||||||
full-round actions; nauseated: single move only. Dropped kinds are
|
condition-specific specializations then run on the budget-legal
|
||||||
reported into ``results``.
|
remainder (their output is a single action, so the budget never
|
||||||
|
re-drops it). Swift consumed by an immediate action; PF1e (CRB,
|
||||||
|
Disabled): a creature at exactly 0 hp is staggered — one move OR one
|
||||||
|
standard action per round, no full-round actions (including
|
||||||
|
full-round and one-round casts and spell_combat); nauseated: single
|
||||||
|
move only. Dropped kinds are reported into ``results``.
|
||||||
"""
|
"""
|
||||||
kept = _action_economy_filter(actions)
|
schedulable = tuple(
|
||||||
|
a
|
||||||
|
for a in actions
|
||||||
|
if not (a.kind == "cast_spell" and self._cast_time_of(a) == "long")
|
||||||
|
)
|
||||||
|
results.extend(
|
||||||
|
ActionResult(kind="cast_spell", ok=False, reason=_REASON_DOWNGRADED_CAST_TIME)
|
||||||
|
for _ in range(len(actions) - len(schedulable))
|
||||||
|
)
|
||||||
|
actions = schedulable
|
||||||
|
kept = _action_economy_filter(actions, cast_time=self._cast_time_of)
|
||||||
results.extend(
|
results.extend(
|
||||||
ActionResult(
|
ActionResult(
|
||||||
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
||||||
@@ -1463,7 +1501,7 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
actions = kept
|
actions = kept
|
||||||
if state.immediate_used:
|
if state.immediate_used:
|
||||||
kept = _without_swift_bonus(actions)
|
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
||||||
results.extend(
|
results.extend(
|
||||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
||||||
for kind in _dropped_kinds(actions, kept)
|
for kind in _dropped_kinds(actions, kept)
|
||||||
@@ -1471,7 +1509,7 @@ class CombatEngine:
|
|||||||
actions = kept
|
actions = kept
|
||||||
state.immediate_used = False
|
state.immediate_used = False
|
||||||
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
|
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
|
||||||
kept = _staggered_filter(actions)
|
kept = _staggered_filter(actions, cast_time=self._cast_time_of)
|
||||||
results.extend(
|
results.extend(
|
||||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
||||||
for kind in _dropped_kinds(actions, kept)
|
for kind in _dropped_kinds(actions, kept)
|
||||||
@@ -5754,10 +5792,21 @@ def _has_slot(engine: CombatEngine, caster: CombatantState, spell_name: str) ->
|
|||||||
return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0
|
return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_castable_this_turn(engine: CombatEngine, spell_name: str) -> bool:
|
||||||
|
"""False only for long casting times (1+ minutes): the budget filter
|
||||||
|
would drop the cast, so the policy must never propose it."""
|
||||||
|
spell = engine.spell_registry.get(spell_name)
|
||||||
|
return spell is None or spell.cast_time != "long"
|
||||||
|
|
||||||
|
|
||||||
def _choose_spell_action(
|
def _choose_spell_action(
|
||||||
engine: CombatEngine, state: CombatantState, target: CombatantState
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
||||||
) -> tuple[Action, ...] | None:
|
) -> tuple[Action, ...] | None:
|
||||||
"""Select and return a spell action if appropriate, else None."""
|
"""Select and return a spell action if appropriate, else None.
|
||||||
|
|
||||||
|
Long-casting-time spells are never selected (they cannot be scheduled
|
||||||
|
within a turn); the heal -> offensive -> buff order is unchanged.
|
||||||
|
"""
|
||||||
caster = state
|
caster = state
|
||||||
spells_known = caster.combatant.spells
|
spells_known = caster.combatant.spells
|
||||||
if not spells_known:
|
if not spells_known:
|
||||||
@@ -5765,29 +5814,38 @@ def _choose_spell_action(
|
|||||||
|
|
||||||
# 1. Heal spell if ally below 50% HP
|
# 1. Heal spell if ally below 50% HP
|
||||||
heal_spell = _find_heal_spell(engine, spells_known)
|
heal_spell = _find_heal_spell(engine, spells_known)
|
||||||
if heal_spell and _has_slot(engine, caster, heal_spell):
|
if (
|
||||||
|
heal_spell
|
||||||
|
and _is_castable_this_turn(engine, heal_spell)
|
||||||
|
and _has_slot(engine, caster, heal_spell)
|
||||||
|
):
|
||||||
ally = _find_ally_needing_heal(engine, caster)
|
ally = _find_ally_needing_heal(engine, caster)
|
||||||
if ally and ally.hp < ally.combatant.hp_max // 2:
|
if ally and ally.hp < ally.combatant.hp_max // 2:
|
||||||
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
||||||
|
|
||||||
# 2. Offensive spell if enemy in range
|
# 2. Offensive spell if enemy in range
|
||||||
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
||||||
if offensive_spell and _has_slot(engine, caster, offensive_spell):
|
if (
|
||||||
|
offensive_spell
|
||||||
|
and _is_castable_this_turn(engine, offensive_spell)
|
||||||
|
and _has_slot(engine, caster, offensive_spell)
|
||||||
|
):
|
||||||
return (
|
return (
|
||||||
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Buff spell
|
# 3. Buff spell
|
||||||
buff_spell = _find_buff_spell(engine, spells_known)
|
buff_spell = _find_buff_spell(engine, spells_known)
|
||||||
if buff_spell and _has_slot(engine, caster, buff_spell):
|
if (
|
||||||
|
buff_spell
|
||||||
|
and _is_castable_this_turn(engine, buff_spell)
|
||||||
|
and _has_slot(engine, caster, buff_spell)
|
||||||
|
):
|
||||||
return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
|
return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_FULL_ROUND_KINDS: frozenset[str] = frozenset(
|
|
||||||
{"full_attack", "charge", "withdraw", "full_round", "run"}
|
|
||||||
)
|
|
||||||
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
||||||
# PF1e (CRB): a nauseated creature can take only a single move action per
|
# PF1e (CRB): a nauseated creature can take only a single move action per
|
||||||
# turn — no standard, no full-round, no attacks, no spellcasting.
|
# turn — no standard, no full-round, no attacks, no spellcasting.
|
||||||
@@ -5795,14 +5853,65 @@ _NAUSEA_MOVE_KINDS: frozenset[str] = frozenset(
|
|||||||
{"move", "5foot_step", "stand_up", "drop_prone"}
|
{"move", "5foot_step", "stand_up", "drop_prone"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Casting-time plumbing for the budget filters: a cast_spell action consumes
|
||||||
|
# the slot its spell's CastTime maps to. The pure filters take a resolver so
|
||||||
|
# tests can drive them without a registry; the engine binds its own.
|
||||||
|
CastTimeResolver = Callable[[Action], CastTime]
|
||||||
|
|
||||||
def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
||||||
|
def _default_cast_time(_action: Action) -> CastTime:
|
||||||
|
return "standard"
|
||||||
|
|
||||||
|
|
||||||
|
def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
|
||||||
|
"""Budget slot consumed by a cast_spell of the given casting time.
|
||||||
|
|
||||||
|
PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes
|
||||||
|
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
|
||||||
|
free-action cast is free. one_round is CLAMPED to the full-round slot:
|
||||||
|
the engine resolves it within the current turn instead of tracking a
|
||||||
|
pending cast across turns (documented approximation). A long casting
|
||||||
|
time (1+ minutes) cannot be scheduled by the turn budget at all — the
|
||||||
|
engine drops such casts before the filter runs, so reaching this
|
||||||
|
function with "long" is a programming error.
|
||||||
|
"""
|
||||||
|
if cast_time == "standard":
|
||||||
|
return "standard"
|
||||||
|
if cast_time == "swift":
|
||||||
|
return "swift"
|
||||||
|
if cast_time in ("immediate", "free"):
|
||||||
|
return "free"
|
||||||
|
if cast_time in ("full_round", "one_round"):
|
||||||
|
return "full_round"
|
||||||
|
msg = f"long casting time cannot be scheduled by the budget filter: {cast_time!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _ActionEconomySlot:
|
||||||
|
"""The budget slot an action consumes, resolving cast_spell by cast time."""
|
||||||
|
if action.kind == "cast_spell":
|
||||||
|
return _cast_time_slot(cast_time(action))
|
||||||
|
return _action_economy_slot(action.kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_swift_bonus(
|
||||||
|
actions: tuple[Action, ...],
|
||||||
|
*,
|
||||||
|
cast_time: CastTimeResolver = _default_cast_time,
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
"""Drop swift/free bonus actions (deed, inspire_courage, swift).
|
"""Drop swift/free bonus actions (deed, inspire_courage, swift).
|
||||||
|
|
||||||
An immediate action consumes the combatant's next swift action; the
|
An immediate action consumes the combatant's next swift action; the
|
||||||
policy-returned sequence is filtered so no swift bonus fires that turn.
|
policy-returned sequence is filtered so no swift bonus fires that turn —
|
||||||
|
including a swift-action cast_spell, which occupies the same slot.
|
||||||
"""
|
"""
|
||||||
return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS)
|
return tuple(
|
||||||
|
a
|
||||||
|
for a in actions
|
||||||
|
if a.kind not in _SWIFT_BONUS_KINDS
|
||||||
|
and not (a.kind == "cast_spell" and _effective_slot(a, cast_time) == "swift")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dropped_kinds(
|
def _dropped_kinds(
|
||||||
@@ -5813,16 +5922,21 @@ def _dropped_kinds(
|
|||||||
return tuple(a.kind for a in before if a.kind not in kept)
|
return tuple(a.kind for a in before if a.kind not in kept)
|
||||||
|
|
||||||
|
|
||||||
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
def _staggered_filter(
|
||||||
|
actions: tuple[Action, ...],
|
||||||
|
*,
|
||||||
|
cast_time: CastTimeResolver = _default_cast_time,
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
"""Staggered: one move OR one standard action per round, no full-round.
|
"""Staggered: one move OR one standard action per round, no full-round.
|
||||||
|
|
||||||
A leading ``full_attack`` (already in range) becomes a single standard
|
A leading ``full_attack`` (already in range) becomes a single standard
|
||||||
``attack``; charge/withdraw/full_round are dropped.
|
``attack``; anything whose effective slot is full-round (charge,
|
||||||
|
withdraw, run, a full-round/one-round cast, spell_combat) is dropped.
|
||||||
"""
|
"""
|
||||||
for action in actions:
|
for action in actions:
|
||||||
if action.kind == "full_attack":
|
if action.kind == "full_attack":
|
||||||
return (Action(kind="attack", target_id=action.target_id),)
|
return (Action(kind="attack", target_id=action.target_id),)
|
||||||
if action.kind in _FULL_ROUND_KINDS:
|
if _effective_slot(action, cast_time) == "full_round":
|
||||||
continue
|
continue
|
||||||
return (action,)
|
return (action,)
|
||||||
return ()
|
return ()
|
||||||
@@ -5843,17 +5957,20 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|||||||
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
|
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
|
||||||
# action + one move action (or one full-round action in place of both), plus
|
# action + one move action (or one full-round action in place of both), plus
|
||||||
# one swift action; free and conditional actions never consume the budget.
|
# one swift action; free and conditional actions never consume the budget.
|
||||||
# Every Action kind is classified into exactly one slot (exhaustive over the
|
# A move action may also be taken INSTEAD of the standard one (conversion:
|
||||||
# Action.kind Literal, locked by tests/test_conformance_action_economy.py):
|
# when the move slot is spent and the standard is free, a second move
|
||||||
# - standard: attack, cast_spell (the engine models no spell casting times —
|
# consumes the standard). Every Action kind is classified into exactly one
|
||||||
# SpellSpec has no cast_time field, so every spell casts as a standard),
|
# slot (exhaustive over the Action.kind Literal, locked by
|
||||||
# maneuver, channel_energy, hex, delay, ready, total_defense, aid_another
|
# tests/test_conformance_action_economy.py):
|
||||||
|
# - standard: attack, maneuver, channel_energy, hex, delay, ready,
|
||||||
|
# total_defense, aid_another; cast_spell occupies the slot its spell's
|
||||||
|
# CastTime maps to (_cast_time_slot) — standard by default
|
||||||
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
|
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
|
||||||
# repair_weapon
|
# repair_weapon
|
||||||
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
||||||
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
||||||
# (spell + full attack in one round), run (4x-speed movement, PF1e full-round);
|
# (spell + full attack in one round), run (4x-speed movement, PF1e
|
||||||
# consumes the standard AND the move slot
|
# full-round); consumes the standard AND the move slot
|
||||||
# - free/conditional (everything else, no budget): wait, free, immediate
|
# - free/conditional (everything else, no budget): wait, free, immediate
|
||||||
# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's
|
# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's
|
||||||
# slots), 5foot_step (free step, mutually exclusive with other movement via
|
# slots), 5foot_step (free step, mutually exclusive with other movement via
|
||||||
@@ -5894,18 +6011,25 @@ def _action_economy_slot(kind: str) -> _ActionEconomySlot:
|
|||||||
return "free"
|
return "free"
|
||||||
|
|
||||||
|
|
||||||
def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
def _action_economy_filter(
|
||||||
|
actions: tuple[Action, ...],
|
||||||
|
*,
|
||||||
|
cast_time: CastTimeResolver = _default_cast_time,
|
||||||
|
) -> tuple[Action, ...]:
|
||||||
"""Degrade a policy sequence to the legal PF1e action economy.
|
"""Degrade a policy sequence to the legal PF1e action economy.
|
||||||
|
|
||||||
Greedy in-order walk: each action is kept while its budget slot is free
|
Greedy in-order walk: each action is kept while its budget slot is free
|
||||||
(one standard, one move, one swift; a full-round action is only legal
|
(one standard, one move, one swift; a full-round action is only legal
|
||||||
while BOTH the standard and the move slot are still free and consumes
|
while BOTH the standard and the move slot are still free and consumes
|
||||||
them all). PF1e (CRB, "Actions in Combat"): a move action can be taken
|
them all). A cast_spell's slot is its spell's casting time resolved by
|
||||||
"instead of" the standard one — when the move slot is already spent and
|
``cast_time`` (long casts are dropped by the engine BEFORE this filter —
|
||||||
the standard slot is still free, a further move action converts the
|
_cast_time_slot raises for them). PF1e (CRB, "Actions in Combat"): a
|
||||||
standard slot (so (move, stand_up) and (move, move) are legal). The kept
|
move action can be taken "instead of" the standard one — when the move
|
||||||
sequence is therefore the earliest order-stable legal
|
slot is already spent and the standard slot is still free, a further
|
||||||
prefix-subsequence; every skipped action is deterministic.
|
move action converts the standard slot (so (move, stand_up) and
|
||||||
|
(move, move) are legal). The kept sequence is therefore the earliest
|
||||||
|
order-stable legal prefix-subsequence; every skipped action is
|
||||||
|
deterministic.
|
||||||
"""
|
"""
|
||||||
kept: list[Action] = []
|
kept: list[Action] = []
|
||||||
spent: dict[_ActionEconomySlot, bool] = {
|
spent: dict[_ActionEconomySlot, bool] = {
|
||||||
@@ -5916,7 +6040,7 @@ def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|||||||
"free": False,
|
"free": False,
|
||||||
}
|
}
|
||||||
for action in actions:
|
for action in actions:
|
||||||
slot = _action_economy_slot(action.kind)
|
slot = _effective_slot(action, cast_time)
|
||||||
if slot == "free":
|
if slot == "free":
|
||||||
kept.append(action)
|
kept.append(action)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
|||||||
|
|
||||||
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
||||||
SaveType = Literal["fort", "ref", "will", "none"]
|
SaveType = Literal["fort", "ref", "will", "none"]
|
||||||
|
# PF1e casting times, normalized to budget codes (see combat._cast_time_slot).
|
||||||
|
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -93,6 +95,7 @@ class SpellSpec:
|
|||||||
range: SpellRange = "medium"
|
range: SpellRange = "medium"
|
||||||
save: SaveType = "none"
|
save: SaveType = "none"
|
||||||
base_dc: int = 10
|
base_dc: int = 10
|
||||||
|
cast_time: CastTime = "standard"
|
||||||
effects: tuple[SpellEffect, ...] = ()
|
effects: tuple[SpellEffect, ...] = ()
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
@@ -183,6 +186,38 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
|
|||||||
return value in ("fort", "ref", "will", "none")
|
return value in ("fort", "ref", "will", "none")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cast_time(value: str) -> TypeGuard[CastTime]:
|
||||||
|
return value in get_args(CastTime)
|
||||||
|
|
||||||
|
|
||||||
|
# RAW phrasings accepted by the JSON loader, mapped onto the budget codes.
|
||||||
|
_CAST_TIME_ALIASES: dict[str, CastTime] = {
|
||||||
|
"1 round": "one_round",
|
||||||
|
"one round": "one_round",
|
||||||
|
"1 full-round action": "full_round",
|
||||||
|
"full-round action": "full_round",
|
||||||
|
"full-round": "full_round",
|
||||||
|
"1 minute": "long",
|
||||||
|
"10 minutes": "long",
|
||||||
|
"1 hour": "long",
|
||||||
|
"standard action": "standard",
|
||||||
|
"swift action": "swift",
|
||||||
|
"immediate action": "immediate",
|
||||||
|
"free action": "free",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cast_time(value: str) -> CastTime:
|
||||||
|
"""Casting time as a budget code, accepting the codes and RAW aliases."""
|
||||||
|
code = value.strip().lower()
|
||||||
|
if _is_cast_time(code):
|
||||||
|
return code
|
||||||
|
if code in _CAST_TIME_ALIASES:
|
||||||
|
return _CAST_TIME_ALIASES[code]
|
||||||
|
msg = f"unknown cast time: {value!r} (expected one of {get_args(CastTime)})"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
|
def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
|
||||||
return value in get_args(StatTarget)
|
return value in get_args(StatTarget)
|
||||||
|
|
||||||
@@ -260,6 +295,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
|||||||
if not _is_save_type(save_raw):
|
if not _is_save_type(save_raw):
|
||||||
msg = f"unknown save type: {save_raw!r}"
|
msg = f"unknown save type: {save_raw!r}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
cast_time = _parse_cast_time(_as_str(data.get("cast_time", "standard")))
|
||||||
return SpellSpec(
|
return SpellSpec(
|
||||||
name=_as_str(data["name"]),
|
name=_as_str(data["name"]),
|
||||||
level=_as_int(data["level"]),
|
level=_as_int(data["level"]),
|
||||||
@@ -267,6 +303,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
|||||||
range=range_raw,
|
range=range_raw,
|
||||||
save=save_raw,
|
save=save_raw,
|
||||||
base_dc=_as_int(data.get("base_dc", 10)),
|
base_dc=_as_int(data.get("base_dc", 10)),
|
||||||
|
cast_time=cast_time,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
description=_as_str(data.get("description", "")),
|
description=_as_str(data.get("description", "")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ turns are legal by construction — verified here so the goldens stay frozen.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.combat import (
|
from pf1e_simulator.combat import (
|
||||||
_ACTION_ECONOMY_FREE_KINDS,
|
_ACTION_ECONOMY_FREE_KINDS,
|
||||||
@@ -23,10 +25,12 @@ from pf1e_simulator.combat import (
|
|||||||
_ACTION_ECONOMY_SWIFT_KINDS,
|
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||||
ACTION_RESULT_REASONS,
|
ACTION_RESULT_REASONS,
|
||||||
Action,
|
Action,
|
||||||
|
CastTimeResolver,
|
||||||
CombatantState,
|
CombatantState,
|
||||||
CombatEngine,
|
CombatEngine,
|
||||||
_action_economy_filter,
|
_action_economy_filter,
|
||||||
_action_economy_slot,
|
_action_economy_slot,
|
||||||
|
_cast_time_slot,
|
||||||
_dropped_kinds,
|
_dropped_kinds,
|
||||||
_dropped_kinds_multiset,
|
_dropped_kinds_multiset,
|
||||||
default_policy,
|
default_policy,
|
||||||
@@ -37,6 +41,9 @@ from pf1e_simulator.rng import ScriptedRng
|
|||||||
from tests.test_combat import make_combatant, make_state
|
from tests.test_combat import make_combatant, make_state
|
||||||
from tests.test_observation_enriched import make_grid
|
from tests.test_observation_enriched import make_grid
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pf1e_simulator.spells import CastTime
|
||||||
|
|
||||||
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
_Kind = Literal[
|
_Kind = Literal[
|
||||||
@@ -112,6 +119,7 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None:
|
|||||||
|
|
||||||
def test_downgrade_reason_is_in_the_result_vocabulary() -> None:
|
def test_downgrade_reason_is_in_the_result_vocabulary() -> None:
|
||||||
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
||||||
|
assert "downgraded:cast_time" in ACTION_RESULT_REASONS
|
||||||
|
|
||||||
|
|
||||||
# ── (b) legal sequences pass through untouched ───────────────────────────────
|
# ── (b) legal sequences pass through untouched ───────────────────────────────
|
||||||
@@ -162,6 +170,62 @@ def test_two_moves_conflict_but_free_steps_do_not() -> None:
|
|||||||
assert _action_economy_filter(steps) == steps
|
assert _action_economy_filter(steps) == steps
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b2) cast time resolves the cast_spell slot ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _cast(spell_name: str) -> Action:
|
||||||
|
return Action(kind="cast_spell", target_id="foe", spell_name=spell_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver(times: dict[str, CastTime]) -> CastTimeResolver:
|
||||||
|
"""Resolver mapping spell names to casting times; unknown -> standard."""
|
||||||
|
return lambda a: times.get(a.spell_name or "", "standard")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cast_time_slot_mapping_is_total_and_rejects_long() -> None:
|
||||||
|
assert _cast_time_slot("standard") == "standard"
|
||||||
|
assert _cast_time_slot("swift") == "swift"
|
||||||
|
assert _cast_time_slot("immediate") == "free"
|
||||||
|
assert _cast_time_slot("free") == "free"
|
||||||
|
assert _cast_time_slot("full_round") == "full_round"
|
||||||
|
# one_round is clamped to the full-round slot (documented approximation).
|
||||||
|
assert _cast_time_slot("one_round") == "full_round"
|
||||||
|
# long is pre-filtered by the engine; a direct call is a programming error.
|
||||||
|
with pytest.raises(ValueError, match="long casting time"):
|
||||||
|
_cast_time_slot("long")
|
||||||
|
|
||||||
|
|
||||||
|
def test_swift_cast_competes_with_the_swift_bonus() -> None:
|
||||||
|
cast = _cast("swift_bolt")
|
||||||
|
deed = _foe("deed")
|
||||||
|
times = {"swift_bolt": "swift"}
|
||||||
|
assert _action_economy_filter((cast, deed), cast_time=_resolver(times)) == (cast,)
|
||||||
|
assert _action_economy_filter((deed, cast), cast_time=_resolver(times)) == (deed,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_round_cast_consumes_standard_and_move() -> None:
|
||||||
|
times = {"big_ritual": "full_round", "summon": "one_round"}
|
||||||
|
cast = _cast("big_ritual")
|
||||||
|
assert _action_economy_filter((_foe("attack"), cast), cast_time=_resolver(times)) == (
|
||||||
|
_foe("attack"),
|
||||||
|
)
|
||||||
|
assert _action_economy_filter((cast, _foe("move")), cast_time=_resolver(times)) == (cast,)
|
||||||
|
# A lone one-round cast is legal (clamped to the full-round slot).
|
||||||
|
summon = _cast("summon")
|
||||||
|
assert _action_economy_filter((summon,), cast_time=_resolver(times)) == (summon,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_immediate_and_free_casts_are_budget_free() -> None:
|
||||||
|
times = {"quick": "immediate", "cantrip": "free"}
|
||||||
|
resolver = _resolver(times)
|
||||||
|
seq = (_cast("quick"), _foe("attack"), _foe("attack"), _cast("cantrip"))
|
||||||
|
assert _action_economy_filter(seq, cast_time=resolver) == (
|
||||||
|
_cast("quick"),
|
||||||
|
_foe("attack"),
|
||||||
|
_cast("cantrip"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── (c) illegal sequences degrade to the earliest legal prefix ───────────────
|
# ── (c) illegal sequences degrade to the earliest legal prefix ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Conformance: spell casting times in the engine's turn pipeline.
|
||||||
|
|
||||||
|
The engine binds its registry-backed resolver (``CombatEngine._cast_time_of``)
|
||||||
|
into the budget filters: a cast_spell occupies the slot its spell's casting
|
||||||
|
time maps to (_cast_time_slot). A long casting time (1+ minutes) cannot be
|
||||||
|
scheduled within a turn, so it is dropped BEFORE the budget filter with the
|
||||||
|
distinct reason ``downgraded:cast_time``. A one-round cast is CLAMPED to the
|
||||||
|
full-round slot (documented approximation: it resolves within the turn
|
||||||
|
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
|
||||||
|
staggered creature, and run is illegal for a nauseated one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||||
|
from pf1e_simulator.effects import ActiveCondition
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import SpellSpec
|
||||||
|
from tests.test_combat import make_combatant, make_state
|
||||||
|
from tests.test_observation_enriched import make_grid
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pf1e_simulator.spells import CastTime
|
||||||
|
|
||||||
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
||||||
|
|
||||||
|
|
||||||
|
def _spell(name: str, cast_time: CastTime) -> SpellSpec:
|
||||||
|
return SpellSpec(name=name, level=1, school="evocation", cast_time=cast_time)
|
||||||
|
|
||||||
|
|
||||||
|
def _duel(
|
||||||
|
policy: PolicyFn,
|
||||||
|
rng: list[int],
|
||||||
|
registry: dict[str, SpellSpec],
|
||||||
|
round_cap: int = 1,
|
||||||
|
) -> CombatEngine:
|
||||||
|
hero = make_state(
|
||||||
|
make_combatant("hero", hp=100, initiative_mod=2), "players", (0, 0)
|
||||||
|
)
|
||||||
|
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 1))
|
||||||
|
return CombatEngine(
|
||||||
|
ScriptedRng(rng), make_grid(), [hero, foe],
|
||||||
|
policy=policy, round_cap=round_cap, spell_registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _take_hero_turn(eng: CombatEngine) -> list[tuple[str, bool, str]]:
|
||||||
|
hero = eng._states[0]
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
return [(r.kind, r.ok, r.reason) for r in hero.last_action_results]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_condition(state: CombatantState, name: str) -> None:
|
||||||
|
state.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cast(spell_name: str) -> Action:
|
||||||
|
return Action(kind="cast_spell", target_id="foe", spell_name=spell_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy(actions: tuple[Action, ...]) -> PolicyFn:
|
||||||
|
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||||
|
return actions if s.combatant.id == "hero" else _WAIT
|
||||||
|
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_cast_is_dropped_with_its_own_reason() -> None:
|
||||||
|
# Given: a spell whose casting time is long (1+ minutes)
|
||||||
|
# When: the policy proposes it as the turn's only action
|
||||||
|
# Then: it never reaches the budget filter; the drop is downgraded:cast_time.
|
||||||
|
eng = _duel(_policy((_cast("Ceremony"),)), [10, 5], {"Ceremony": _spell("Ceremony", "long")})
|
||||||
|
assert _take_hero_turn(eng) == [
|
||||||
|
("cast_spell", False, "downgraded:cast_time")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_round_cast_consumes_the_whole_turn() -> None:
|
||||||
|
# Given: a one-round cast (clamped to the full-round slot)
|
||||||
|
# When: a move action follows it in the sequence
|
||||||
|
# Then: the full-round cast spent standard+move; the move is surplus.
|
||||||
|
eng = _duel(
|
||||||
|
_policy((_cast("Summon"), Action(kind="move", target_id="foe"))),
|
||||||
|
[10, 5],
|
||||||
|
{"Summon": _spell("Summon", "one_round")},
|
||||||
|
)
|
||||||
|
# The hero has no slots: the cast itself is rejected at execution (no_slot)
|
||||||
|
# but it still consumed the turn — only the move is budget-dropped.
|
||||||
|
assert _take_hero_turn(eng) == [
|
||||||
|
("move", False, "downgraded:action_economy"),
|
||||||
|
("cast_spell", False, "no_slot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staggered_caster_cannot_run() -> None:
|
||||||
|
# Given: a disabled (staggered) caster; When: it proposes run (full-round)
|
||||||
|
# Then: the staggered filter drops it.
|
||||||
|
eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {})
|
||||||
|
eng._states[0].hp = 0
|
||||||
|
assert _take_hero_turn(eng) == [("run", False, "downgraded:staggered")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_staggered_caster_cannot_spell_combat() -> None:
|
||||||
|
# spell_combat occupies the full-round slot: illegal while staggered.
|
||||||
|
eng = _duel(_policy((Action(kind="spell_combat", target_id="foe"),)), [10, 5], {})
|
||||||
|
eng._states[0].hp = 0
|
||||||
|
assert _take_hero_turn(eng) == [
|
||||||
|
("spell_combat", False, "downgraded:staggered")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nauseated_caster_cannot_run() -> None:
|
||||||
|
# PF1e: a nauseated creature may take a single MOVE action; run is
|
||||||
|
# full-round, so the nauseated filter drops it.
|
||||||
|
eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {})
|
||||||
|
_add_condition(eng._states[0], "nauseated")
|
||||||
|
assert _take_hero_turn(eng) == [("run", False, "downgraded:nauseated")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_swift_cast_is_dropped_after_immediate_used() -> None:
|
||||||
|
# Given: the combatant's next swift was consumed by an immediate action
|
||||||
|
# When: the policy proposes a swift-action cast plus a standard attack
|
||||||
|
# Then: the swift cast is removed (downgraded:immediate_swift), attack runs.
|
||||||
|
eng = _duel(
|
||||||
|
_policy((_cast("Quick Bolt"), Action(kind="attack", target_id="foe"))),
|
||||||
|
[10, 5, 8, 3],
|
||||||
|
{"Quick Bolt": _spell("Quick Bolt", "swift")},
|
||||||
|
)
|
||||||
|
eng._states[0].immediate_used = True
|
||||||
|
reasons = _take_hero_turn(eng)
|
||||||
|
assert ("cast_spell", False, "downgraded:immediate_swift") in reasons
|
||||||
|
assert ("attack", True, "ok") in reasons
|
||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
|
||||||
from pf1e_simulator.dice import parse_dice
|
from pf1e_simulator.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
@@ -181,6 +183,50 @@ class TestSpellLoading:
|
|||||||
assert isinstance(eff, BuffEffect)
|
assert isinstance(eff, BuffEffect)
|
||||||
assert len(eff.modifiers) == 2
|
assert len(eff.modifiers) == 2
|
||||||
|
|
||||||
|
def test_summon_monster_casts_for_one_round(self) -> None:
|
||||||
|
# PF1e: Summon Monster I/II have a 1-round casting time.
|
||||||
|
for name in ("summon_monster_i", "summon_monster_ii"):
|
||||||
|
assert load_spell(_SPELLS_DIR / f"{name}.json").cast_time == "one_round"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpellCastTime:
|
||||||
|
"""Given: SpellSpec and the JSON loader casting-time field
|
||||||
|
When: specs are built or spell JSON parsed
|
||||||
|
Then: the default is "standard", RAW phrasings map onto budget codes,
|
||||||
|
and unknown values are rejected."""
|
||||||
|
|
||||||
|
def test_spec_defaults_to_standard(self) -> None:
|
||||||
|
spell = SpellSpec(name="X", level=1, school="evocation")
|
||||||
|
assert spell.cast_time == "standard"
|
||||||
|
|
||||||
|
def _load(self, tmp_path: Path, cast_time: str) -> SpellSpec:
|
||||||
|
spell_file = tmp_path / "test_spell.json"
|
||||||
|
spell_file.write_text(
|
||||||
|
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
|
||||||
|
f' "range": "medium", "save": "ref", "cast_time": "{cast_time}",'
|
||||||
|
' "effects": []}'
|
||||||
|
)
|
||||||
|
return load_spell(spell_file)
|
||||||
|
|
||||||
|
def test_loader_parses_raw_aliases(self, tmp_path: Path) -> None:
|
||||||
|
cases = {
|
||||||
|
"1 round": "one_round",
|
||||||
|
"1 minute": "long",
|
||||||
|
"full-round": "full_round",
|
||||||
|
"swift action": "swift",
|
||||||
|
}
|
||||||
|
for raw, code in cases.items():
|
||||||
|
assert self._load(tmp_path, raw).cast_time == code, raw
|
||||||
|
|
||||||
|
def test_loader_accepts_code_values(self, tmp_path: Path) -> None:
|
||||||
|
for code in ("standard", "swift", "immediate", "free", "full_round",
|
||||||
|
"one_round", "long"):
|
||||||
|
assert self._load(tmp_path, code).cast_time == code, code
|
||||||
|
|
||||||
|
def test_loader_rejects_unknown(self, tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="unknown cast time"):
|
||||||
|
self._load(tmp_path, "2 hours")
|
||||||
|
|
||||||
|
|
||||||
class TestSpellRangeFt:
|
class TestSpellRangeFt:
|
||||||
"""Given: a spell with a range category
|
"""Given: a spell with a range category
|
||||||
|
|||||||
Reference in New Issue
Block a user