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

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

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

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