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
+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", "")),
)