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