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:
+170
-46
@@ -4,10 +4,13 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
- Action economy: a normal turn grants one standard action + one move action
|
||||
(or one full-round action), plus swift/free/immediate. The policy returns an
|
||||
ordered sequence of `Action` per turn; the engine enforces the budget on it
|
||||
(X4, `_action_economy_filter`): surplus standards/moves/swifts and a
|
||||
full-round action conflicting with an already-spent standard/move are
|
||||
(X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's
|
||||
casting time (long casts are dropped before the budget with
|
||||
`downgraded:cast_time`), surplus standards/moves/swifts and a full-round
|
||||
action conflicting with an already-spent standard/move are
|
||||
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
||||
`downgraded:action_economy`; the condition-specific filters then apply as
|
||||
`downgraded:action_economy`; a move action may convert a still-free standard
|
||||
slot; the condition-specific filters then apply as
|
||||
before. The engine executes the remaining actions in order,
|
||||
stopping early if the actor drops or one side is wiped. `default_policy`
|
||||
returns `(full_attack,)` when already in range, `(charge,)` when a
|
||||
@@ -114,6 +117,7 @@ from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||
from pf1e_simulator.spells import (
|
||||
AreaEffect,
|
||||
BuffEffect,
|
||||
CastTime,
|
||||
ConditionEffect,
|
||||
DamageEffect,
|
||||
HealEffect,
|
||||
@@ -782,13 +786,17 @@ class Action:
|
||||
# no_weapon (aid another without a usable melee weapon) and
|
||||
# unreachable_destination (run to an unhonourable destination). X4 adds
|
||||
# downgraded:action_economy for actions the general per-turn budget filter
|
||||
# drops from the policy sequence (second standard, second move, second
|
||||
# swift, full-round conflicting with standard+move).
|
||||
# drops from the policy sequence (second standard, second move when no
|
||||
# standard is left to convert, second swift, full-round conflicting with
|
||||
# standard+move). The cast-time package adds downgraded:cast_time for
|
||||
# cast_spell actions whose spell has a long casting time (1+ minutes): the
|
||||
# engine cannot schedule them within a turn, so they are dropped before the
|
||||
# budget filter runs.
|
||||
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||
"downgraded:staggered", "downgraded:nauseated",
|
||||
"downgraded:action_economy",
|
||||
"downgraded:action_economy", "downgraded:cast_time",
|
||||
"delayed", "already_delayed", "ready",
|
||||
"not_prone", "already_prone", "unreachable_destination",
|
||||
"weapon_unavailable", "invalid_destination",
|
||||
@@ -800,6 +808,7 @@ _REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
||||
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
||||
_REASON_DELAYED = "delayed"
|
||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||
_REASON_READY = "ready"
|
||||
@@ -1436,6 +1445,20 @@ class CombatEngine:
|
||||
state.last_action_results = results
|
||||
return self._winner_side() is not None
|
||||
|
||||
def _cast_time_of(self, action: Action) -> CastTime:
|
||||
"""Resolve a cast_spell action's casting time from the registry.
|
||||
|
||||
Unknown spell names (and non-cast actions) default to "standard".
|
||||
Future hook (out of scope here): for a spontaneous caster this
|
||||
closure would read state.combatant.spontaneous_caster and
|
||||
action.metamagic — a spontaneous cast with a non-empty metamagic
|
||||
feat and no Quickened Spell becomes full_round (unless the spell is
|
||||
already one_round or long). The Callable[[Action], CastTime]
|
||||
resolver signature already accommodates the state-aware variant.
|
||||
"""
|
||||
spell = self._spell_registry.get(action.spell_name or "")
|
||||
return spell.cast_time if spell is not None else "standard"
|
||||
|
||||
def _filter_turn_actions(
|
||||
self,
|
||||
state: CombatantState,
|
||||
@@ -1444,17 +1467,32 @@ class CombatEngine:
|
||||
) -> tuple[Action, ...]:
|
||||
"""Downgrade the policy's action sequence to the legal action economy.
|
||||
|
||||
Composition order (X4): the general per-turn budget (CRB, "Actions in
|
||||
Combat": one standard + one move, or one full-round, plus one swift)
|
||||
is enforced first on every sequence; the condition-specific
|
||||
specializations then run on the budget-legal remainder (their output
|
||||
is a single action, so the budget never re-drops it). Swift consumed
|
||||
by an immediate action; PF1e (CRB, Disabled): a creature at exactly
|
||||
0 hp is staggered — one move OR one standard action per round, no
|
||||
full-round actions; nauseated: single move only. Dropped kinds are
|
||||
reported into ``results``.
|
||||
Composition order: a cast-time pre-pass first drops every cast_spell
|
||||
whose spell has a long casting time (1+ minutes — the engine cannot
|
||||
schedule it within a turn; reported as downgraded:cast_time). Then
|
||||
the general per-turn budget (CRB, "Actions in Combat": one standard
|
||||
+ one move, or one full-round, plus one swift; a move action may be
|
||||
taken instead of the standard) is enforced on every sequence, with
|
||||
each cast_spell occupying the slot of its resolved casting time. The
|
||||
condition-specific specializations then run on the budget-legal
|
||||
remainder (their output is a single action, so the budget never
|
||||
re-drops it). Swift consumed by an immediate action; PF1e (CRB,
|
||||
Disabled): a creature at exactly 0 hp is staggered — one move OR one
|
||||
standard action per round, no full-round actions (including
|
||||
full-round and one-round casts and spell_combat); nauseated: single
|
||||
move only. Dropped kinds are reported into ``results``.
|
||||
"""
|
||||
kept = _action_economy_filter(actions)
|
||||
schedulable = tuple(
|
||||
a
|
||||
for a in actions
|
||||
if not (a.kind == "cast_spell" and self._cast_time_of(a) == "long")
|
||||
)
|
||||
results.extend(
|
||||
ActionResult(kind="cast_spell", ok=False, reason=_REASON_DOWNGRADED_CAST_TIME)
|
||||
for _ in range(len(actions) - len(schedulable))
|
||||
)
|
||||
actions = schedulable
|
||||
kept = _action_economy_filter(actions, cast_time=self._cast_time_of)
|
||||
results.extend(
|
||||
ActionResult(
|
||||
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
||||
@@ -1463,7 +1501,7 @@ class CombatEngine:
|
||||
)
|
||||
actions = kept
|
||||
if state.immediate_used:
|
||||
kept = _without_swift_bonus(actions)
|
||||
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
||||
results.extend(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
||||
for kind in _dropped_kinds(actions, kept)
|
||||
@@ -1471,7 +1509,7 @@ class CombatEngine:
|
||||
actions = kept
|
||||
state.immediate_used = False
|
||||
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
|
||||
kept = _staggered_filter(actions)
|
||||
kept = _staggered_filter(actions, cast_time=self._cast_time_of)
|
||||
results.extend(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
||||
for kind in _dropped_kinds(actions, kept)
|
||||
@@ -5754,10 +5792,21 @@ def _has_slot(engine: CombatEngine, caster: CombatantState, spell_name: str) ->
|
||||
return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0
|
||||
|
||||
|
||||
def _is_castable_this_turn(engine: CombatEngine, spell_name: str) -> bool:
|
||||
"""False only for long casting times (1+ minutes): the budget filter
|
||||
would drop the cast, so the policy must never propose it."""
|
||||
spell = engine.spell_registry.get(spell_name)
|
||||
return spell is None or spell.cast_time != "long"
|
||||
|
||||
|
||||
def _choose_spell_action(
|
||||
engine: CombatEngine, state: CombatantState, target: CombatantState
|
||||
) -> tuple[Action, ...] | None:
|
||||
"""Select and return a spell action if appropriate, else None."""
|
||||
"""Select and return a spell action if appropriate, else None.
|
||||
|
||||
Long-casting-time spells are never selected (they cannot be scheduled
|
||||
within a turn); the heal -> offensive -> buff order is unchanged.
|
||||
"""
|
||||
caster = state
|
||||
spells_known = caster.combatant.spells
|
||||
if not spells_known:
|
||||
@@ -5765,29 +5814,38 @@ def _choose_spell_action(
|
||||
|
||||
# 1. Heal spell if ally below 50% HP
|
||||
heal_spell = _find_heal_spell(engine, spells_known)
|
||||
if heal_spell and _has_slot(engine, caster, heal_spell):
|
||||
if (
|
||||
heal_spell
|
||||
and _is_castable_this_turn(engine, heal_spell)
|
||||
and _has_slot(engine, caster, heal_spell)
|
||||
):
|
||||
ally = _find_ally_needing_heal(engine, caster)
|
||||
if ally and ally.hp < ally.combatant.hp_max // 2:
|
||||
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
||||
|
||||
# 2. Offensive spell if enemy in range
|
||||
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
||||
if offensive_spell and _has_slot(engine, caster, offensive_spell):
|
||||
if (
|
||||
offensive_spell
|
||||
and _is_castable_this_turn(engine, offensive_spell)
|
||||
and _has_slot(engine, caster, offensive_spell)
|
||||
):
|
||||
return (
|
||||
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
||||
)
|
||||
|
||||
# 3. Buff spell
|
||||
buff_spell = _find_buff_spell(engine, spells_known)
|
||||
if buff_spell and _has_slot(engine, caster, buff_spell):
|
||||
if (
|
||||
buff_spell
|
||||
and _is_castable_this_turn(engine, buff_spell)
|
||||
and _has_slot(engine, caster, buff_spell)
|
||||
):
|
||||
return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_FULL_ROUND_KINDS: frozenset[str] = frozenset(
|
||||
{"full_attack", "charge", "withdraw", "full_round", "run"}
|
||||
)
|
||||
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
||||
# PF1e (CRB): a nauseated creature can take only a single move action per
|
||||
# turn — no standard, no full-round, no attacks, no spellcasting.
|
||||
@@ -5795,14 +5853,65 @@ _NAUSEA_MOVE_KINDS: frozenset[str] = frozenset(
|
||||
{"move", "5foot_step", "stand_up", "drop_prone"}
|
||||
)
|
||||
|
||||
# Casting-time plumbing for the budget filters: a cast_spell action consumes
|
||||
# the slot its spell's CastTime maps to. The pure filters take a resolver so
|
||||
# tests can drive them without a registry; the engine binds its own.
|
||||
CastTimeResolver = Callable[[Action], CastTime]
|
||||
|
||||
def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
|
||||
def _default_cast_time(_action: Action) -> CastTime:
|
||||
return "standard"
|
||||
|
||||
|
||||
def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
|
||||
"""Budget slot consumed by a cast_spell of the given casting time.
|
||||
|
||||
PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes
|
||||
that kind of action; an immediate action is off-turn and consumes the
|
||||
next swift (handled via immediate_used, so it is budget-free here); a
|
||||
free-action cast is free. one_round is CLAMPED to the full-round slot:
|
||||
the engine resolves it within the current turn instead of tracking a
|
||||
pending cast across turns (documented approximation). A long casting
|
||||
time (1+ minutes) cannot be scheduled by the turn budget at all — the
|
||||
engine drops such casts before the filter runs, so reaching this
|
||||
function with "long" is a programming error.
|
||||
"""
|
||||
if cast_time == "standard":
|
||||
return "standard"
|
||||
if cast_time == "swift":
|
||||
return "swift"
|
||||
if cast_time in ("immediate", "free"):
|
||||
return "free"
|
||||
if cast_time in ("full_round", "one_round"):
|
||||
return "full_round"
|
||||
msg = f"long casting time cannot be scheduled by the budget filter: {cast_time!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _ActionEconomySlot:
|
||||
"""The budget slot an action consumes, resolving cast_spell by cast time."""
|
||||
if action.kind == "cast_spell":
|
||||
return _cast_time_slot(cast_time(action))
|
||||
return _action_economy_slot(action.kind)
|
||||
|
||||
|
||||
def _without_swift_bonus(
|
||||
actions: tuple[Action, ...],
|
||||
*,
|
||||
cast_time: CastTimeResolver = _default_cast_time,
|
||||
) -> tuple[Action, ...]:
|
||||
"""Drop swift/free bonus actions (deed, inspire_courage, swift).
|
||||
|
||||
An immediate action consumes the combatant's next swift action; the
|
||||
policy-returned sequence is filtered so no swift bonus fires that turn.
|
||||
policy-returned sequence is filtered so no swift bonus fires that turn —
|
||||
including a swift-action cast_spell, which occupies the same slot.
|
||||
"""
|
||||
return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS)
|
||||
return tuple(
|
||||
a
|
||||
for a in actions
|
||||
if a.kind not in _SWIFT_BONUS_KINDS
|
||||
and not (a.kind == "cast_spell" and _effective_slot(a, cast_time) == "swift")
|
||||
)
|
||||
|
||||
|
||||
def _dropped_kinds(
|
||||
@@ -5813,16 +5922,21 @@ def _dropped_kinds(
|
||||
return tuple(a.kind for a in before if a.kind not in kept)
|
||||
|
||||
|
||||
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
def _staggered_filter(
|
||||
actions: tuple[Action, ...],
|
||||
*,
|
||||
cast_time: CastTimeResolver = _default_cast_time,
|
||||
) -> tuple[Action, ...]:
|
||||
"""Staggered: one move OR one standard action per round, no full-round.
|
||||
|
||||
A leading ``full_attack`` (already in range) becomes a single standard
|
||||
``attack``; charge/withdraw/full_round are dropped.
|
||||
``attack``; anything whose effective slot is full-round (charge,
|
||||
withdraw, run, a full-round/one-round cast, spell_combat) is dropped.
|
||||
"""
|
||||
for action in actions:
|
||||
if action.kind == "full_attack":
|
||||
return (Action(kind="attack", target_id=action.target_id),)
|
||||
if action.kind in _FULL_ROUND_KINDS:
|
||||
if _effective_slot(action, cast_time) == "full_round":
|
||||
continue
|
||||
return (action,)
|
||||
return ()
|
||||
@@ -5843,17 +5957,20 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
|
||||
# action + one move action (or one full-round action in place of both), plus
|
||||
# one swift action; free and conditional actions never consume the budget.
|
||||
# Every Action kind is classified into exactly one slot (exhaustive over the
|
||||
# Action.kind Literal, locked by tests/test_conformance_action_economy.py):
|
||||
# - standard: attack, cast_spell (the engine models no spell casting times —
|
||||
# SpellSpec has no cast_time field, so every spell casts as a standard),
|
||||
# maneuver, channel_energy, hex, delay, ready, total_defense, aid_another
|
||||
# A move action may also be taken INSTEAD of the standard one (conversion:
|
||||
# when the move slot is spent and the standard is free, a second move
|
||||
# consumes the standard). Every Action kind is classified into exactly one
|
||||
# slot (exhaustive over the Action.kind Literal, locked by
|
||||
# tests/test_conformance_action_economy.py):
|
||||
# - standard: attack, maneuver, channel_energy, hex, delay, ready,
|
||||
# total_defense, aid_another; cast_spell occupies the slot its spell's
|
||||
# CastTime maps to (_cast_time_slot) — standard by default
|
||||
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
|
||||
# repair_weapon
|
||||
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
||||
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
||||
# (spell + full attack in one round), run (4x-speed movement, PF1e full-round);
|
||||
# consumes the standard AND the move slot
|
||||
# (spell + full attack in one round), run (4x-speed movement, PF1e
|
||||
# full-round); consumes the standard AND the move slot
|
||||
# - free/conditional (everything else, no budget): wait, free, immediate
|
||||
# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's
|
||||
# slots), 5foot_step (free step, mutually exclusive with other movement via
|
||||
@@ -5894,18 +6011,25 @@ def _action_economy_slot(kind: str) -> _ActionEconomySlot:
|
||||
return "free"
|
||||
|
||||
|
||||
def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
def _action_economy_filter(
|
||||
actions: tuple[Action, ...],
|
||||
*,
|
||||
cast_time: CastTimeResolver = _default_cast_time,
|
||||
) -> tuple[Action, ...]:
|
||||
"""Degrade a policy sequence to the legal PF1e action economy.
|
||||
|
||||
Greedy in-order walk: each action is kept while its budget slot is free
|
||||
(one standard, one move, one swift; a full-round action is only legal
|
||||
while BOTH the standard and the move slot are still free and consumes
|
||||
them all). PF1e (CRB, "Actions in Combat"): a move action can be taken
|
||||
"instead of" the standard one — when the move slot is already spent and
|
||||
the standard slot is still free, a further move action converts the
|
||||
standard slot (so (move, stand_up) and (move, move) are legal). The kept
|
||||
sequence is therefore the earliest order-stable legal
|
||||
prefix-subsequence; every skipped action is deterministic.
|
||||
them all). A cast_spell's slot is its spell's casting time resolved by
|
||||
``cast_time`` (long casts are dropped by the engine BEFORE this filter —
|
||||
_cast_time_slot raises for them). PF1e (CRB, "Actions in Combat"): a
|
||||
move action can be taken "instead of" the standard one — when the move
|
||||
slot is already spent and the standard slot is still free, a further
|
||||
move action converts the standard slot (so (move, stand_up) and
|
||||
(move, move) are legal). The kept sequence is therefore the earliest
|
||||
order-stable legal prefix-subsequence; every skipped action is
|
||||
deterministic.
|
||||
"""
|
||||
kept: list[Action] = []
|
||||
spent: dict[_ActionEconomySlot, bool] = {
|
||||
@@ -5916,7 +6040,7 @@ def _action_economy_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
"free": False,
|
||||
}
|
||||
for action in actions:
|
||||
slot = _action_economy_slot(action.kind)
|
||||
slot = _effective_slot(action, cast_time)
|
||||
if slot == "free":
|
||||
kept.append(action)
|
||||
continue
|
||||
|
||||
@@ -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