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:
@@ -13,7 +13,9 @@ turns are legal by construction — verified here so the goldens stay frozen.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
_ACTION_ECONOMY_FREE_KINDS,
|
||||
@@ -23,10 +25,12 @@ from pf1e_simulator.combat import (
|
||||
_ACTION_ECONOMY_SWIFT_KINDS,
|
||||
ACTION_RESULT_REASONS,
|
||||
Action,
|
||||
CastTimeResolver,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
_action_economy_filter,
|
||||
_action_economy_slot,
|
||||
_cast_time_slot,
|
||||
_dropped_kinds,
|
||||
_dropped_kinds_multiset,
|
||||
default_policy,
|
||||
@@ -37,6 +41,9 @@ from pf1e_simulator.rng import ScriptedRng
|
||||
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, ...]]
|
||||
|
||||
_Kind = Literal[
|
||||
@@ -112,6 +119,7 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None:
|
||||
|
||||
def test_downgrade_reason_is_in_the_result_vocabulary() -> None:
|
||||
assert "downgraded:action_economy" in ACTION_RESULT_REASONS
|
||||
assert "downgraded:cast_time" in ACTION_RESULT_REASONS
|
||||
|
||||
|
||||
# ── (b) legal sequences pass through untouched ───────────────────────────────
|
||||
@@ -162,6 +170,62 @@ def test_two_moves_conflict_but_free_steps_do_not() -> None:
|
||||
assert _action_economy_filter(steps) == steps
|
||||
|
||||
|
||||
# ── (b2) cast time resolves the cast_spell slot ──────────────────────────────
|
||||
|
||||
|
||||
def _cast(spell_name: str) -> Action:
|
||||
return Action(kind="cast_spell", target_id="foe", spell_name=spell_name)
|
||||
|
||||
|
||||
def _resolver(times: dict[str, CastTime]) -> CastTimeResolver:
|
||||
"""Resolver mapping spell names to casting times; unknown -> standard."""
|
||||
return lambda a: times.get(a.spell_name or "", "standard")
|
||||
|
||||
|
||||
def test_cast_time_slot_mapping_is_total_and_rejects_long() -> None:
|
||||
assert _cast_time_slot("standard") == "standard"
|
||||
assert _cast_time_slot("swift") == "swift"
|
||||
assert _cast_time_slot("immediate") == "free"
|
||||
assert _cast_time_slot("free") == "free"
|
||||
assert _cast_time_slot("full_round") == "full_round"
|
||||
# one_round is clamped to the full-round slot (documented approximation).
|
||||
assert _cast_time_slot("one_round") == "full_round"
|
||||
# long is pre-filtered by the engine; a direct call is a programming error.
|
||||
with pytest.raises(ValueError, match="long casting time"):
|
||||
_cast_time_slot("long")
|
||||
|
||||
|
||||
def test_swift_cast_competes_with_the_swift_bonus() -> None:
|
||||
cast = _cast("swift_bolt")
|
||||
deed = _foe("deed")
|
||||
times = {"swift_bolt": "swift"}
|
||||
assert _action_economy_filter((cast, deed), cast_time=_resolver(times)) == (cast,)
|
||||
assert _action_economy_filter((deed, cast), cast_time=_resolver(times)) == (deed,)
|
||||
|
||||
|
||||
def test_full_round_cast_consumes_standard_and_move() -> None:
|
||||
times = {"big_ritual": "full_round", "summon": "one_round"}
|
||||
cast = _cast("big_ritual")
|
||||
assert _action_economy_filter((_foe("attack"), cast), cast_time=_resolver(times)) == (
|
||||
_foe("attack"),
|
||||
)
|
||||
assert _action_economy_filter((cast, _foe("move")), cast_time=_resolver(times)) == (cast,)
|
||||
# A lone one-round cast is legal (clamped to the full-round slot).
|
||||
summon = _cast("summon")
|
||||
assert _action_economy_filter((summon,), cast_time=_resolver(times)) == (summon,)
|
||||
|
||||
|
||||
def test_immediate_and_free_casts_are_budget_free() -> None:
|
||||
times = {"quick": "immediate", "cantrip": "free"}
|
||||
resolver = _resolver(times)
|
||||
seq = (_cast("quick"), _foe("attack"), _foe("attack"), _cast("cantrip"))
|
||||
assert _action_economy_filter(seq, cast_time=resolver) == (
|
||||
_cast("quick"),
|
||||
_foe("attack"),
|
||||
_cast("cantrip"),
|
||||
)
|
||||
|
||||
|
||||
# ── (c) illegal sequences degrade to the earliest legal prefix ───────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user