Compare commits

...

4 Commits

Author SHA1 Message Date
ctan b3398c16ce feat(observation): expose spell cast time in the snapshot
The per-spell Observation dict gains the additive cast_time key: the
registry spell's budget code on a hit, "standard" on a miss (mirroring
the engine's resolver fallback), so an LLM policy can schedule casts
against the action budget it sees reported back.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 14:13:13 +02:00
ctan c5426ffe34 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>
2026-09-11 14:09:58 +02:00
ctan 0997c8d1b5 feat(rules): allow a move action in place of a standard action
PF1e CRB (Actions in Combat): a move action can be taken instead of the
standard one. The budget filter now converts a second move action onto
the standard slot when the move slot is spent and the standard is still
free, so (move, stand_up) and (move, move) are legal; a third move (or
any further standard) is still surplus. The greedy walk is expressed
with a spent-slot map to stay inside the C901 complexity ceiling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 13:53:45 +02:00
ctan e44ec75f2b fix(rules): classify run as a full-round action
PF1e CRB: running is a full-round action, not a move action. The budget
filter now classifies run as full-round (consumes the standard AND the
move slot), _FULL_ROUND_KINDS includes it for the staggered filter, and
the nauseated filter drops it (run is no longer move-like). The
moved_this_turn/running flags are unchanged; the _execute_run
moved_this_turn early return stays as defense in depth. default_policy
never emits run, so the golden transcripts are untouched.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 13:48:58 +02:00
11 changed files with 594 additions and 105 deletions
+62 -21
View File
@@ -360,7 +360,11 @@ Règles modélisées :
compartiment du budget — standard, mouvement, swift, round complet, ou
libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget
(≤ 1 standard **ou** 1 round complet, ≤ 1 mouvement, ≤ 1 swift ; un round
complet consacre à la fois le standard ET le mouvement). La dégradation est
complet consacre à la fois le standard ET le mouvement). **Conversion
standard → mouvement** (CRB : une action de mouvement peut se prendre « à la
place » de l'action standard) : quand le compartiment mouvement est déjà
occupé et que le standard est encore libre, un second mouvement consomme le
standard — `(move, stand_up)` et `(move, move)` sont donc légaux. La dégradation est
**déterministe et stable en ordre** : parcours glouton dans l'ordre, chaque
action est gardée tant que son compartiment est libre ; le résultat est donc le
plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et
@@ -371,27 +375,57 @@ Règles modélisées :
`(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`.
Classification (exhaustive sur le `Literal` des kinds, verrouillée par
`tests/test_conformance_action_economy.py`) : **standard** = `attack`,
`cast_spell` (le moteur ne modélise pas de temps d'incantation — `SpellSpec`
n'a pas de champ `cast_time`, donc tout sort s'incante comme une action
standard), `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; **mouvement** = `move`, `stand_up`,
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
voir « Temps d'incantation » plus bas ; standard par défaut) ;
**mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon`, `run` (round complet en RAW, mais modélisé ici comme une
mécanique de mouvement posant `moved_this_turn` et déjà exclu de
`_FULL_ROUND_KINDS` — cohérence assumée) ; **swift** = `swift`, `deed`,
`repair_weapon` ; **swift** = `swift`, `deed`,
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round) ;
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
en posant `moved_this_turn` et `running`) ;
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
basculement Power Attack). Ordre de composition : le budget général est
appliqué EN PREMIER, puis les filtres spécialisés (immédiat/accablé/nausée)
s'exercent sur le reste déjà légal (leur sortie est une action unique, donc
jamais re-filtrée par le budget). L'ordre de dégradation est celui de la
politique : la première action légale d'un compartiment gagne, les suivantes
sont excédentaires.
basculement Power Attack). Ordre de composition : le pré-filtre des temps
d'incantation « longs » est appliqué EN PREMIER (voir « Temps d'incantation »
ci-dessous), puis le budget général, puis les filtres spécialisés
(immédiat/accablé/nausée) s'exercent sur le reste déjà légal (leur sortie est
une action unique, donc jamais re-filtrée par le budget). L'ordre de
dégradation est celui de la politique : la première action légale d'un
compartiment gagne, les suivantes sont excédentaires.
- Temps d'incantation (`SpellSpec.cast_time`, conformance économie d'action) :
chaque sort porte un temps d'incantation normalisé en codes de budget —
`standard`, `swift`, `immediate`, `free`, `full_round`, `one_round`, `long`
(défaut `standard`). Le chargeur JSON accepte aussi les formulations RAW :
`"1 round"`/`"one round"` → `one_round`, `"1 full-round action"`/
`"full-round action"`/`"full-round"` → `full_round`, `"1 minute"`/
`"10 minutes"`/`"1 hour"`/`"long"` → `long`, `"standard action"`,
`"swift action"`, `"immediate action"`, `"free action"` ; une valeur
inconnue est une erreur de données (`ValueError`). Mapping vers le budget :
`standard` → standard ; `swift` → swift (il concurrence donc deed/inspire
courage et est aussi retiré quand le swift est consommé par une immédiate,
via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste
hors-tour et consomme le prochain swift) ; `full_round` → round complet ;
`one_round` → **round complet par clamp** — approximation documentée : le
moteur ne tient pas d'incantation en cours entre les tours, le sort
d'1 round se résout au bout du tour courant au lieu du suivant ;
`long` (≥ 1 minute) → **jeté avant le filtre de budget**, rapporté comme
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
jamais un sort à temps long). Le moteur résout le code via sa registry
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
filtres purs sous forme de résolveur `CastTimeResolver`. Seuls
`Summon Monster I/II` déclarent `"1 round"` dans `data/spells/` ; les autres
sorts exotiques (Bladed Dash, Silence…) restent au défaut `standard` — hors
du périmètre de ce paquet. Hors du périmètre (hook futur, commenté dans
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
d'incantation à un round complet sans don Quickened — le signature
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
@@ -477,9 +511,11 @@ Règles modélisées :
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
par le budget général d'action (X4 — action excédentaire retirée de la
séquence de la politique) ou par les filtres staggered/nauseated (ou swift
consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:staggered` / `downgraded:nauseated` /
séquence de la politique), par le pré-filtre des temps d'incantation longs
(sort `long` jamais planifiable dans un tour) ou par les filtres
staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:cast_time` /
`downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ;
retard valide → `ok=True, reason="delayed"`, second retard du même round →
`ok=False, reason="already_delayed"` ; préparation valide →
@@ -941,7 +977,9 @@ Règles modélisées :
spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4), area {shape,
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
gabarit de zone structuré, None si le sort n'a pas de zone)),
gabarit de zone structuré, None si le sort n'a pas de zone), cast_time
(code de temps d'incantation, « standard » pour un sort inconnu du
registre)),
spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles :
(niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
@@ -1086,10 +1124,13 @@ la lecture mais non appliquée dans la résolution.
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
sauvegarde, DC de base, temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
descripteurs de sauvegarde
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"cast_time": "1 round",
"effects": [
{
"type": "summon",
+1
View File
@@ -5,6 +5,7 @@
"range": "close",
"save": "none",
"base_dc": 10,
"cast_time": "1 round",
"effects": [
{
"type": "summon",
+197 -60
View File
@@ -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)
@@ -3393,6 +3431,10 @@ class CombatEngine:
"""
kind = "run"
if state.moved_this_turn:
# Defense in depth, mirroring the _move/_5foot_step no-op-ok
# convention. Unreachable through the filtered path: run is a
# full-round action, so no move-slot action can precede it in a
# kept sequence (the budget filter would have dropped one).
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
@@ -4532,6 +4574,7 @@ class CombatEngine:
"half_on_save": False,
"save_negates": False,
"area": None,
"cast_time": "standard",
}
half_on_save = _spell_half_on_save(spell)
return {
@@ -4545,6 +4588,7 @@ class CombatEngine:
"half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save,
"area": _spell_area_dict(spell),
"cast_time": spell.cast_time,
}
def _cast_spell(
@@ -5750,10 +5794,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:
@@ -5761,27 +5816,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"})
_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.
@@ -5789,14 +5855,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(
@@ -5807,16 +5924,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 ()
@@ -5837,18 +5959,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, run (a full-round action in RAW, but this engine models it
# as move machinery that sets moved_this_turn and already excludes it from
# _FULL_ROUND_KINDS — kept consistent, see README)
# 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); 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
@@ -5860,13 +5984,13 @@ _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
"delay", "ready", "total_defense", "aid_another",
})
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
"move", "stand_up", "retrieve_weapon", "repair_weapon", "run",
"move", "stand_up", "retrieve_weapon", "repair_weapon",
})
_ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
{"swift", "deed", "inspire_courage"}
)
_ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({
"full_attack", "charge", "withdraw", "full_round", "spell_combat",
"full_attack", "charge", "withdraw", "full_round", "spell_combat", "run",
})
_ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({
"wait", "free", "immediate", "5foot_step", "drop_prone",
@@ -5889,38 +6013,51 @@ 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). 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] = []
standard_used = False
move_used = False
swift_used = False
spent: dict[_ActionEconomySlot, bool] = {
"standard": False,
"move": False,
"swift": False,
"full_round": False,
"free": False,
}
for action in actions:
slot = _action_economy_slot(action.kind)
if slot == "standard":
if standard_used:
slot = _effective_slot(action, cast_time)
if slot == "free":
kept.append(action)
continue
standard_used = True
elif slot == "move":
if move_used:
if slot == "full_round":
if spent["standard"] or spent["move"]:
continue
move_used = True
elif slot == "swift":
if swift_used:
spent["standard"] = True
spent["move"] = True
kept.append(action)
continue
swift_used = True
elif slot == "full_round":
if standard_used or move_used:
if slot == "move" and spent["move"]:
slot = "standard" # conversion: a move action consumes the standard slot
if spent[slot]:
continue
standard_used = True
move_used = True
spent[slot] = True
kept.append(action)
return tuple(kept)
+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", "")),
)
+7 -2
View File
@@ -438,12 +438,17 @@ def test_run_and_aid_without_target_are_rejected() -> None:
eng = _duo(policy, [10, 5], round_cap=1)
t = eng.run().transcript
hero = eng._states[0]
# run is a full-round action: it consumes the standard AND the move slot,
# so the budget filter drops the following aid_another as a surplus
# standard before it can even reach the missing-target gate.
assert hero.last_action_results == [
ActionResult(
kind="aid_another", ok=False, reason="downgraded:action_economy"
),
ActionResult(kind="run", ok=False, reason="missing_target"),
ActionResult(kind="aid_another", ok=False, reason="missing_target"),
]
assert "round 1 hero: run rejected (missing_target)" in t
assert "round 1 hero: aid_another rejected (missing_target)" in t
assert "round 1 hero: aid_another rejected (missing_target)" not in t
assert hero.pos == (0, 0)
assert hero.running is False
+79 -6
View File
@@ -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[
@@ -92,9 +99,8 @@ def test_slot_per_kind_matches_pf1e_action_type() -> None:
# move actions
"move": "move", "stand_up": "move", "retrieve_weapon": "move",
"repair_weapon": "move",
# run: RAW full-round, but the engine models it as move machinery
# (sets moved_this_turn, excluded from _FULL_ROUND_KINDS) — consistent.
"run": "move",
# run: PF1e full-round action (4x speed); consumes standard + move.
"run": "full_round",
# swift actions
"swift": "swift", "deed": "swift", "inspire_courage": "swift",
# full-round actions
@@ -113,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 ───────────────────────────────
@@ -145,14 +152,80 @@ def test_legal_sequences_are_kept_unchanged() -> None:
def test_two_moves_conflict_but_free_steps_do_not() -> None:
# stand_up is a move action: a second move is surplus.
# PF1e conversion: a move action taken while the move slot is spent
# consumes the standard slot instead — two moves are legal, so
# (move, stand_up) is kept whole.
actions = (_foe("move"), _foe("stand_up"))
assert _action_economy_filter(actions) == (_foe("move"),)
assert _action_economy_filter(actions) == actions
moves = (_foe("move"), _foe("move"))
assert _action_economy_filter(moves) == moves
# The third move has neither slot left: standard and move both spent.
assert _action_economy_filter((*moves, _foe("attack"))) == moves
assert _action_economy_filter((*moves, _foe("full_attack"))) == moves
# run is full-round: it cannot follow a move (both slots needed upfront).
assert _action_economy_filter((_foe("move"), _foe("run"))) == (_foe("move"),)
assert _action_economy_filter((_foe("run"), _foe("move"))) == (_foe("run"),)
# 5foot_step is free: two steps do not consume the move slot.
steps = (_foe("5foot_step"), _foe("5foot_step"))
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 ───────────────
+145
View File
@@ -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
+1
View File
@@ -532,6 +532,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"half_on_save": False,
"save_negates": False,
"area": None,
"cast_time": "standard",
},
)
+3 -1
View File
@@ -127,7 +127,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
"""Given: a known Fireball snapshot dict
When: all keys and values are checked
Then: the nine pre-U7a keys are present with unchanged values, and the
exact key set is the old nine plus the additive "area"."""
exact key set is the old nine plus the additive "area" and "cast_time"."""
s = _spell_dict_for(_FIREBALL, "Fireball")
assert s["name"] == "Fireball"
assert s["level"] == 3
@@ -140,6 +140,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
assert s["summary"] == "area 20ft"
assert s["half_on_save"] is True
assert s["save_negates"] is False
assert s["cast_time"] == "standard"
assert set(s.keys()) == {
"name",
"level",
@@ -151,6 +152,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
"half_on_save",
"save_negates",
"area",
"cast_time",
}
+46
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from pathlib import Path
import pytest
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
@@ -181,6 +183,50 @@ class TestSpellLoading:
assert isinstance(eff, BuffEffect)
assert len(eff.modifiers) == 2
def test_summon_monster_casts_for_one_round(self) -> None:
# PF1e: Summon Monster I/II have a 1-round casting time.
for name in ("summon_monster_i", "summon_monster_ii"):
assert load_spell(_SPELLS_DIR / f"{name}.json").cast_time == "one_round"
class TestSpellCastTime:
"""Given: SpellSpec and the JSON loader casting-time field
When: specs are built or spell JSON parsed
Then: the default is "standard", RAW phrasings map onto budget codes,
and unknown values are rejected."""
def test_spec_defaults_to_standard(self) -> None:
spell = SpellSpec(name="X", level=1, school="evocation")
assert spell.cast_time == "standard"
def _load(self, tmp_path: Path, cast_time: str) -> SpellSpec:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
f' "range": "medium", "save": "ref", "cast_time": "{cast_time}",'
' "effects": []}'
)
return load_spell(spell_file)
def test_loader_parses_raw_aliases(self, tmp_path: Path) -> None:
cases = {
"1 round": "one_round",
"1 minute": "long",
"full-round": "full_round",
"swift action": "swift",
}
for raw, code in cases.items():
assert self._load(tmp_path, raw).cast_time == code, raw
def test_loader_accepts_code_values(self, tmp_path: Path) -> None:
for code in ("standard", "swift", "immediate", "free", "full_round",
"one_round", "long"):
assert self._load(tmp_path, code).cast_time == code, code
def test_loader_rejects_unknown(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="unknown cast time"):
self._load(tmp_path, "2 hours")
class TestSpellRangeFt:
"""Given: a spell with a range category