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