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
+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