From c5426ffe3451e4354ddf81ffd98d4ae117222313 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 14:09:58 +0200 Subject: [PATCH] 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 --- README.md | 85 ++++++--- data/spells/summon_monster_i.json | 1 + data/spells/summon_monster_ii.json | 1 + src/pf1e_simulator/combat.py | 216 ++++++++++++++++++----- src/pf1e_simulator/spells.py | 37 ++++ tests/test_conformance_action_economy.py | 66 ++++++- tests/test_conformance_cast_time.py | 145 +++++++++++++++ tests/test_spells.py | 46 +++++ 8 files changed, 525 insertions(+), 72 deletions(-) create mode 100644 tests/test_conformance_cast_time.py diff --git a/README.md b/README.md index ca1f91c..28ecb60 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/spells/summon_monster_i.json b/data/spells/summon_monster_i.json index e957929..0369403 100644 --- a/data/spells/summon_monster_i.json +++ b/data/spells/summon_monster_i.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "cast_time": "1 round", "effects": [ { "type": "summon", diff --git a/data/spells/summon_monster_ii.json b/data/spells/summon_monster_ii.json index f96ec70..1828b81 100644 --- a/data/spells/summon_monster_ii.json +++ b/data/spells/summon_monster_ii.json @@ -5,6 +5,7 @@ "range": "close", "save": "none", "base_dc": 10, + "cast_time": "1 round", "effects": [ { "type": "summon", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 13a210f..e89168d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index dde7be8..493cac0 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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", "")), ) diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index e1e4dfc..2dd1939 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -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 ─────────────── diff --git a/tests/test_conformance_cast_time.py b/tests/test_conformance_cast_time.py new file mode 100644 index 0000000..dbfab73 --- /dev/null +++ b/tests/test_conformance_cast_time.py @@ -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 diff --git a/tests/test_spells.py b/tests/test_spells.py index 146c4eb..457a0c4 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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