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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user