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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"range": "close",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "1 round",
|
||||
"effects": [
|
||||
{
|
||||
"type": "summon",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"range": "close",
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "1 round",
|
||||
"effects": [
|
||||
{
|
||||
"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
|
||||
(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
|
||||
(X4, `_action_economy_filter`): surplus standards/moves/swifts and a
|
||||
full-round action conflicting with an already-spent standard/move are
|
||||
(X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's
|
||||
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
|
||||
`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,
|
||||
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||
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 (
|
||||
AreaEffect,
|
||||
BuffEffect,
|
||||
CastTime,
|
||||
ConditionEffect,
|
||||
DamageEffect,
|
||||
HealEffect,
|
||||
@@ -782,13 +786,17 @@ class Action:
|
||||
# no_weapon (aid another without a usable melee weapon) and
|
||||
# unreachable_destination (run to an unhonourable destination). X4 adds
|
||||
# downgraded:action_economy for actions the general per-turn budget filter
|
||||
# drops from the policy sequence (second standard, second move, second
|
||||
# swift, full-round conflicting with standard+move).
|
||||
# drops from the policy sequence (second standard, second move when no
|
||||
# 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({
|
||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||
"downgraded:staggered", "downgraded:nauseated",
|
||||
"downgraded:action_economy",
|
||||
"downgraded:action_economy", "downgraded:cast_time",
|
||||
"delayed", "already_delayed", "ready",
|
||||
"not_prone", "already_prone", "unreachable_destination",
|
||||
"weapon_unavailable", "invalid_destination",
|
||||
@@ -800,6 +808,7 @@ _REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
||||
_REASON_DELAYED = "delayed"
|
||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||
_REASON_READY = "ready"
|
||||
@@ -1436,6 +1445,20 @@ class CombatEngine:
|
||||
state.last_action_results = results
|
||||
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(
|
||||
self,
|
||||
state: CombatantState,
|
||||
@@ -1444,17 +1467,32 @@ class CombatEngine:
|
||||
) -> tuple[Action, ...]:
|
||||
"""Downgrade the policy's action sequence to the legal action economy.
|
||||
|
||||
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
||||
Combat": one standard + one move, or one full-round, plus one swift)
|
||||
is enforced first on every sequence; the condition-specific
|
||||
specializations then run on the budget-legal 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; nauseated: single move only. Dropped kinds are
|
||||
reported into ``results``.
|
||||
Composition order: a cast-time pre-pass first drops every cast_spell
|
||||
whose spell has a long casting time (1+ minutes — the engine cannot
|
||||
schedule it within a turn; reported as downgraded:cast_time). Then
|
||||
the general per-turn budget (CRB, "Actions in Combat": one standard
|
||||
+ one move, or one full-round, plus one swift; a move action may be
|
||||
taken instead of the standard) is enforced on every sequence, with
|
||||
each cast_spell occupying the slot of its resolved casting time. The
|
||||
condition-specific specializations then run on the budget-legal
|
||||
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(
|
||||
ActionResult(
|
||||
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
||||
@@ -1463,7 +1501,7 @@ class CombatEngine:
|
||||
)
|
||||
actions = kept
|
||||
if state.immediate_used:
|
||||
kept = _without_swift_bonus(actions)
|
||||
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
||||
results.extend(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
||||
for kind in _dropped_kinds(actions, kept)
|
||||
@@ -1471,7 +1509,7 @@ class CombatEngine:
|
||||
actions = kept
|
||||
state.immediate_used = False
|
||||
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(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
engine: CombatEngine, state: CombatantState, target: CombatantState
|
||||
) -> 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
|
||||
spells_known = caster.combatant.spells
|
||||
if not spells_known:
|
||||
@@ -5765,29 +5814,38 @@ def _choose_spell_action(
|
||||
|
||||
# 1. Heal spell if ally below 50% HP
|
||||
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)
|
||||
if ally and ally.hp < ally.combatant.hp_max // 2:
|
||||
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
||||
|
||||
# 2. Offensive spell if enemy in range
|
||||
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 (
|
||||
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
||||
)
|
||||
|
||||
# 3. Buff spell
|
||||
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 None
|
||||
|
||||
|
||||
_FULL_ROUND_KINDS: frozenset[str] = frozenset(
|
||||
{"full_attack", "charge", "withdraw", "full_round", "run"}
|
||||
)
|
||||
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
||||
# PF1e (CRB): a nauseated creature can take only a single move action per
|
||||
# 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"}
|
||||
)
|
||||
|
||||
# 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).
|
||||
|
||||
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(
|
||||
@@ -5813,16 +5922,21 @@ def _dropped_kinds(
|
||||
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.
|
||||
|
||||
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:
|
||||
if action.kind == "full_attack":
|
||||
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
|
||||
return (action,)
|
||||
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
|
||||
# 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.
|
||||
# Every Action kind is classified into exactly one slot (exhaustive over the
|
||||
# Action.kind Literal, locked by tests/test_conformance_action_economy.py):
|
||||
# - standard: attack, cast_spell (the engine models no spell casting times —
|
||||
# SpellSpec has no cast_time field, so every spell casts as a standard),
|
||||
# maneuver, channel_energy, hex, delay, ready, total_defense, aid_another
|
||||
# A move action may also be taken INSTEAD of the standard one (conversion:
|
||||
# when the move slot is spent and the standard is free, a second move
|
||||
# consumes the standard). Every Action kind is classified into exactly one
|
||||
# slot (exhaustive over the Action.kind Literal, locked by
|
||||
# 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),
|
||||
# repair_weapon
|
||||
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
||||
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
||||
# (spell + full attack in one round), run (4x-speed movement, PF1e full-round);
|
||||
# consumes the standard AND the move slot
|
||||
# (spell + full attack in one round), run (4x-speed movement, PF1e
|
||||
# full-round); consumes the standard AND the move slot
|
||||
# - free/conditional (everything else, no budget): wait, free, immediate
|
||||
# (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
|
||||
@@ -5894,18 +6011,25 @@ def _action_economy_slot(kind: str) -> _ActionEconomySlot:
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
"instead of" the standard one — when the move slot is already spent and
|
||||
the standard slot is still free, a further 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.
|
||||
them all). A cast_spell's slot is its spell's casting time resolved by
|
||||
``cast_time`` (long casts are dropped by the engine BEFORE this filter —
|
||||
_cast_time_slot raises for them). PF1e (CRB, "Actions in Combat"): a
|
||||
move action can be taken "instead of" the standard one — when the move
|
||||
slot is already spent and the standard slot is still free, a further
|
||||
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] = []
|
||||
spent: dict[_ActionEconomySlot, bool] = {
|
||||
@@ -5916,7 +6040,7 @@ def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
"free": False,
|
||||
}
|
||||
for action in actions:
|
||||
slot = _action_economy_slot(action.kind)
|
||||
slot = _effective_slot(action, cast_time)
|
||||
if slot == "free":
|
||||
kept.append(action)
|
||||
continue
|
||||
|
||||
@@ -29,6 +29,8 @@ SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
||||
|
||||
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
||||
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)
|
||||
@@ -93,6 +95,7 @@ class SpellSpec:
|
||||
range: SpellRange = "medium"
|
||||
save: SaveType = "none"
|
||||
base_dc: int = 10
|
||||
cast_time: CastTime = "standard"
|
||||
effects: tuple[SpellEffect, ...] = ()
|
||||
description: str = ""
|
||||
|
||||
@@ -183,6 +186,38 @@ def _is_save_type(value: str) -> TypeGuard[SaveType]:
|
||||
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]:
|
||||
return value in get_args(StatTarget)
|
||||
|
||||
@@ -260,6 +295,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
||||
if not _is_save_type(save_raw):
|
||||
msg = f"unknown save type: {save_raw!r}"
|
||||
raise ValueError(msg)
|
||||
cast_time = _parse_cast_time(_as_str(data.get("cast_time", "standard")))
|
||||
return SpellSpec(
|
||||
name=_as_str(data["name"]),
|
||||
level=_as_int(data["level"]),
|
||||
@@ -267,6 +303,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
||||
range=range_raw,
|
||||
save=save_raw,
|
||||
base_dc=_as_int(data.get("base_dc", 10)),
|
||||
cast_time=cast_time,
|
||||
effects=effects,
|
||||
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 collections.abc import Callable
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
_ACTION_ECONOMY_FREE_KINDS,
|
||||
@@ -23,10 +25,12 @@ from pf1e_simulator.combat import (
|
||||
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||
ACTION_RESULT_REASONS,
|
||||
Action,
|
||||
CastTimeResolver,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
_action_economy_filter,
|
||||
_action_economy_slot,
|
||||
_cast_time_slot,
|
||||
_dropped_kinds,
|
||||
_dropped_kinds_multiset,
|
||||
default_policy,
|
||||
@@ -37,6 +41,9 @@ from pf1e_simulator.rng import ScriptedRng
|
||||
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, ...]]
|
||||
|
||||
_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:
|
||||
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
||||
assert "downgraded:cast_time" in ACTION_RESULT_REASONS
|
||||
|
||||
|
||||
# ── (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
|
||||
|
||||
|
||||
# ── (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 ───────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
@@ -181,6 +183,50 @@ class TestSpellLoading:
|
||||
assert isinstance(eff, BuffEffect)
|
||||
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:
|
||||
"""Given: a spell with a range category
|
||||
|
||||
Reference in New Issue
Block a user