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:
@@ -0,0 +1,145 @@
|
||||
"""Conformance: spell casting times in the engine's turn pipeline.
|
||||
|
||||
The engine binds its registry-backed resolver (``CombatEngine._cast_time_of``)
|
||||
into the budget filters: a cast_spell occupies the slot its spell's casting
|
||||
time maps to (_cast_time_slot). A long casting time (1+ minutes) cannot be
|
||||
scheduled within a turn, so it is dropped BEFORE the budget filter with the
|
||||
distinct reason ``downgraded:cast_time``. A one-round cast is CLAMPED to the
|
||||
full-round slot (documented approximation: it resolves within the turn
|
||||
instead of spanning rounds). Swift casts compete with the swift bonus (and
|
||||
are removed by a consumed swift); full-round casts are illegal for a
|
||||
staggered creature, and run is illegal for a nauseated one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||
from pf1e_simulator.effects import ActiveCondition
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import SpellSpec
|
||||
from tests.test_combat import make_combatant, make_state
|
||||
from tests.test_observation_enriched import make_grid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.spells import CastTime
|
||||
|
||||
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||
|
||||
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
||||
|
||||
|
||||
def _spell(name: str, cast_time: CastTime) -> SpellSpec:
|
||||
return SpellSpec(name=name, level=1, school="evocation", cast_time=cast_time)
|
||||
|
||||
|
||||
def _duel(
|
||||
policy: PolicyFn,
|
||||
rng: list[int],
|
||||
registry: dict[str, SpellSpec],
|
||||
round_cap: int = 1,
|
||||
) -> CombatEngine:
|
||||
hero = make_state(
|
||||
make_combatant("hero", hp=100, initiative_mod=2), "players", (0, 0)
|
||||
)
|
||||
foe = make_state(make_combatant("foe", hp=100), "monsters", (0, 1))
|
||||
return CombatEngine(
|
||||
ScriptedRng(rng), make_grid(), [hero, foe],
|
||||
policy=policy, round_cap=round_cap, spell_registry=registry,
|
||||
)
|
||||
|
||||
|
||||
def _take_hero_turn(eng: CombatEngine) -> list[tuple[str, bool, str]]:
|
||||
hero = eng._states[0]
|
||||
eng._roll_initiative()
|
||||
eng._take_turn(hero)
|
||||
return [(r.kind, r.ok, r.reason) for r in hero.last_action_results]
|
||||
|
||||
|
||||
def _add_condition(state: CombatantState, name: str) -> None:
|
||||
state.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cast(spell_name: str) -> Action:
|
||||
return Action(kind="cast_spell", target_id="foe", spell_name=spell_name)
|
||||
|
||||
|
||||
def _policy(actions: tuple[Action, ...]) -> PolicyFn:
|
||||
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
|
||||
return actions if s.combatant.id == "hero" else _WAIT
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def test_long_cast_is_dropped_with_its_own_reason() -> None:
|
||||
# Given: a spell whose casting time is long (1+ minutes)
|
||||
# When: the policy proposes it as the turn's only action
|
||||
# Then: it never reaches the budget filter; the drop is downgraded:cast_time.
|
||||
eng = _duel(_policy((_cast("Ceremony"),)), [10, 5], {"Ceremony": _spell("Ceremony", "long")})
|
||||
assert _take_hero_turn(eng) == [
|
||||
("cast_spell", False, "downgraded:cast_time")
|
||||
]
|
||||
|
||||
|
||||
def test_one_round_cast_consumes_the_whole_turn() -> None:
|
||||
# Given: a one-round cast (clamped to the full-round slot)
|
||||
# When: a move action follows it in the sequence
|
||||
# Then: the full-round cast spent standard+move; the move is surplus.
|
||||
eng = _duel(
|
||||
_policy((_cast("Summon"), Action(kind="move", target_id="foe"))),
|
||||
[10, 5],
|
||||
{"Summon": _spell("Summon", "one_round")},
|
||||
)
|
||||
# The hero has no slots: the cast itself is rejected at execution (no_slot)
|
||||
# but it still consumed the turn — only the move is budget-dropped.
|
||||
assert _take_hero_turn(eng) == [
|
||||
("move", False, "downgraded:action_economy"),
|
||||
("cast_spell", False, "no_slot"),
|
||||
]
|
||||
|
||||
|
||||
def test_staggered_caster_cannot_run() -> None:
|
||||
# Given: a disabled (staggered) caster; When: it proposes run (full-round)
|
||||
# Then: the staggered filter drops it.
|
||||
eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {})
|
||||
eng._states[0].hp = 0
|
||||
assert _take_hero_turn(eng) == [("run", False, "downgraded:staggered")]
|
||||
|
||||
|
||||
def test_staggered_caster_cannot_spell_combat() -> None:
|
||||
# spell_combat occupies the full-round slot: illegal while staggered.
|
||||
eng = _duel(_policy((Action(kind="spell_combat", target_id="foe"),)), [10, 5], {})
|
||||
eng._states[0].hp = 0
|
||||
assert _take_hero_turn(eng) == [
|
||||
("spell_combat", False, "downgraded:staggered")
|
||||
]
|
||||
|
||||
|
||||
def test_nauseated_caster_cannot_run() -> None:
|
||||
# PF1e: a nauseated creature may take a single MOVE action; run is
|
||||
# full-round, so the nauseated filter drops it.
|
||||
eng = _duel(_policy((Action(kind="run", destination=(3, 3)),)), [10, 5], {})
|
||||
_add_condition(eng._states[0], "nauseated")
|
||||
assert _take_hero_turn(eng) == [("run", False, "downgraded:nauseated")]
|
||||
|
||||
|
||||
def test_swift_cast_is_dropped_after_immediate_used() -> None:
|
||||
# Given: the combatant's next swift was consumed by an immediate action
|
||||
# When: the policy proposes a swift-action cast plus a standard attack
|
||||
# Then: the swift cast is removed (downgraded:immediate_swift), attack runs.
|
||||
eng = _duel(
|
||||
_policy((_cast("Quick Bolt"), Action(kind="attack", target_id="foe"))),
|
||||
[10, 5, 8, 3],
|
||||
{"Quick Bolt": _spell("Quick Bolt", "swift")},
|
||||
)
|
||||
eng._states[0].immediate_used = True
|
||||
reasons = _take_hero_turn(eng)
|
||||
assert ("cast_spell", False, "downgraded:immediate_swift") in reasons
|
||||
assert ("attack", True, "ok") in reasons
|
||||
Reference in New Issue
Block a user