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