feat(combat): add ready action with triggers
This commit is contained in:
@@ -306,6 +306,24 @@ Règles modélisées :
|
|||||||
round est rejeté (`already_delayed`), et le drapeau se réinitialise au début
|
round est rejeté (`already_delayed`), et le drapeau se réinitialise au début
|
||||||
de chaque round. `default_policy` ne choisit pas le retard (disponible via
|
de chaque round. `default_policy` ne choisit pas le retard (disponible via
|
||||||
une politique personnalisée).
|
une politique personnalisée).
|
||||||
|
- Préparation (`Action(kind="ready", ready_trigger=...)`, action standard) : le
|
||||||
|
combattant se prépare à agir quand un événement déclencheur survient. Le
|
||||||
|
payload est encodé dans les champs porteurs de l'action, comme l'immédiate :
|
||||||
|
`spell_name` → incantation, `maneuver` → manœuvre (avec `direction` /
|
||||||
|
`trick_condition`), sinon attaque simple sur `target_id` (les capacités de
|
||||||
|
classe en payload ne sont pas modélisées). Quatre déclencheurs, mappés aux
|
||||||
|
événements de réaction existants : `enemy_enters_reach` ← pas de mouvement
|
||||||
|
d'un ennemi (simplification documentée : la portée menacée n'est pas
|
||||||
|
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
|
||||||
|
ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ←
|
||||||
|
dégâts subis par un allié du préparant infligés par un ennemi. Quand
|
||||||
|
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
|
||||||
|
de swift** (au plus une action préparée par événement, sans cascade), puis
|
||||||
|
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
|
||||||
|
remplace la précédente ; le reste du tour (action de mouvement) reste
|
||||||
|
exécutable. Le snapshot expose l'état via la clé `readied`
|
||||||
|
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
||||||
|
(disponible via une politique personnalisée).
|
||||||
- Retour d'exécution des actions : chaque action produit un `ActionResult`
|
- Retour d'exécution des actions : chaque action produit un `ActionResult`
|
||||||
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
|
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
|
||||||
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
|
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
|
||||||
@@ -315,7 +333,8 @@ Règles modélisées :
|
|||||||
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
||||||
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
|
||||||
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
retard valide → `ok=True, reason="delayed"`, second retard du même round →
|
||||||
`ok=False, reason="already_delayed"`.
|
`ok=False, reason="already_delayed"` ; préparation valide →
|
||||||
|
`ok=True, reason="ready"`.
|
||||||
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
|
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
|
||||||
résout `count` balayages indépendants dans la même action ; les balayages
|
résout `count` balayages indépendants dans la même action ; les balayages
|
||||||
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
|
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
|
||||||
@@ -350,7 +369,9 @@ Règles modélisées :
|
|||||||
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
|
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
|
||||||
événement — et consomme le prochain swift de l'état (immédiate pendant le
|
événement — et consomme le prochain swift de l'état (immédiate pendant le
|
||||||
tour : exécutée, consomme aussi le prochain swift — simplification
|
tour : exécutée, consomme aussi le prochain swift — simplification
|
||||||
documentée).
|
documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes
|
||||||
|
événements **avant** et indépendamment de la politique de réaction (voir
|
||||||
|
« Préparation » plus haut).
|
||||||
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
||||||
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||||||
visée.
|
visée.
|
||||||
@@ -570,7 +591,9 @@ Règles modélisées :
|
|||||||
inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd
|
inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd
|
||||||
plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie),
|
plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie),
|
||||||
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
|
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
|
||||||
dernier tour, pour que la policy apprenne les actions rejetées/dégradées).
|
dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
|
||||||
|
readied ({kind, trigger} de l'action préparée ou None — préparation en
|
||||||
|
cours, consommée quand le déclencheur la fait tirer).
|
||||||
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
||||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||||
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
||||||
|
|||||||
+145
-10
@@ -13,7 +13,10 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
hook: each resolved attack, movement step, spell cast, and damage application
|
hook: each resolved attack, movement step, spell cast, and damage application
|
||||||
emits a ``ReactionEvent``; the first combatant whose policy returns an
|
emits a ``ReactionEvent``; the first combatant whose policy returns an
|
||||||
``Action(kind="immediate")`` executes it immediately, and its next turn's
|
``Action(kind="immediate")`` executes it immediately, and its next turn's
|
||||||
swift action is consumed. Flanking, full attack, charge, withdraw, and
|
swift action is consumed. A readied action (``Action(kind="ready")``,
|
||||||
|
standard) stores a carrier-field payload that fires off-turn through the
|
||||||
|
same ``ReactionEvent`` machinery when its ``ready_trigger`` matches an
|
||||||
|
enemy event, then is consumed. Flanking, full attack, charge, withdraw, and
|
||||||
5-foot step are modeled; attacks of opportunity are resolved inline (see
|
5-foot step are modeled; attacks of opportunity are resolved inline (see
|
||||||
below).
|
below).
|
||||||
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
||||||
@@ -114,6 +117,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
|
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
|
||||||
|
|
||||||
|
ReadyTrigger = Literal[
|
||||||
|
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"
|
||||||
|
]
|
||||||
|
|
||||||
from pf1e_simulator.los import (
|
from pf1e_simulator.los import (
|
||||||
cover_bonus_cells,
|
cover_bonus_cells,
|
||||||
has_line_of_effect_cells,
|
has_line_of_effect_cells,
|
||||||
@@ -350,6 +357,11 @@ class CombatantState:
|
|||||||
# chose to act just before (None = end of the round). Reset at round start.
|
# chose to act just before (None = end of the round). Reset at round start.
|
||||||
delayed_this_round: bool = False
|
delayed_this_round: bool = False
|
||||||
delay_before: str | None = None
|
delay_before: str | None = None
|
||||||
|
# Ready action (not in the frozen data model): the concrete payload the
|
||||||
|
# combatant prepared with its standard action, and the trigger event that
|
||||||
|
# fires it. Cleared when the readied action executes (one-shot).
|
||||||
|
readied: Action | None = None
|
||||||
|
readied_trigger: ReadyTrigger | None = None
|
||||||
|
|
||||||
def usable_attacks(self) -> list[AttackSpec]:
|
def usable_attacks(self) -> list[AttackSpec]:
|
||||||
return [
|
return [
|
||||||
@@ -509,7 +521,10 @@ class Action:
|
|||||||
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
|
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
|
||||||
no-op. ``delay`` is a standard action that forgoes the turn to act later
|
no-op. ``delay`` is a standard action that forgoes the turn to act later
|
||||||
in the same round (end of order, or just before ``target_id``). The engine
|
in the same round (end of order, or just before ``target_id``). The engine
|
||||||
executes a policy-returned sequence per turn.
|
executes a policy-returned sequence per turn. ``ready`` is a standard
|
||||||
|
action that prepares the carrier-field payload (spell / maneuver / attack
|
||||||
|
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
|
||||||
|
``ReactionEvent``; the readied action is then consumed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: Literal[
|
kind: Literal[
|
||||||
@@ -517,7 +532,7 @@ class Action:
|
|||||||
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||||
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
"activate_power_attack", "delay",
|
"activate_power_attack", "delay", "ready",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
spell_name: str | None = None
|
spell_name: str | None = None
|
||||||
@@ -530,18 +545,19 @@ class Action:
|
|||||||
direction: tuple[int, int] | None = None # steering for directional maneuvers
|
direction: tuple[int, int] | None = None # steering for directional maneuvers
|
||||||
trick_condition: TrickCondition | None = None # dirty trick condition choice
|
trick_condition: TrickCondition | None = None # dirty trick condition choice
|
||||||
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
|
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
|
||||||
|
ready_trigger: ReadyTrigger | None = None # ready action trigger event
|
||||||
|
|
||||||
|
|
||||||
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
||||||
# missing_target and the downgraded:* reasons; no_weapon, out_of_range,
|
# missing_target and the downgraded:* reasons; no_weapon, out_of_range,
|
||||||
# no_line_of_effect, no_slot and insufficient_resource are declared here for
|
# no_line_of_effect, no_slot and insufficient_resource are declared here for
|
||||||
# the deeper execution-feedback package (U6b). U3 adds delayed /
|
# the deeper execution-feedback package (U6b). U3 adds delayed /
|
||||||
# already_delayed for the delay action.
|
# already_delayed for the delay action. U4 adds ready for the ready action.
|
||||||
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||||
"downgraded:staggered", "downgraded:nauseated",
|
"downgraded:staggered", "downgraded:nauseated",
|
||||||
"delayed", "already_delayed",
|
"delayed", "already_delayed", "ready",
|
||||||
})
|
})
|
||||||
_REASON_OK = "ok"
|
_REASON_OK = "ok"
|
||||||
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
||||||
@@ -551,6 +567,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
|||||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||||
_REASON_DELAYED = "delayed"
|
_REASON_DELAYED = "delayed"
|
||||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||||
|
_REASON_READY = "ready"
|
||||||
|
|
||||||
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
||||||
# them as explicit rejections instead of silent no-ops).
|
# them as explicit rejections instead of silent no-ops).
|
||||||
@@ -940,6 +957,11 @@ class CombatEngine:
|
|||||||
{"kind": r.kind, "ok": r.ok, "reason": r.reason}
|
{"kind": r.kind, "ok": r.ok, "reason": r.reason}
|
||||||
for r in state.last_action_results
|
for r in state.last_action_results
|
||||||
),
|
),
|
||||||
|
"readied": (
|
||||||
|
{"kind": state.readied.kind, "trigger": state.readied_trigger}
|
||||||
|
if state.readied is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _pair_snapshot(
|
def _pair_snapshot(
|
||||||
@@ -2386,6 +2408,8 @@ class CombatEngine:
|
|||||||
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
||||||
if kind == "delay":
|
if kind == "delay":
|
||||||
return self._execute_delay(state, target, action)
|
return self._execute_delay(state, target, action)
|
||||||
|
if kind == "ready":
|
||||||
|
return self._execute_ready(state, action)
|
||||||
self._dispatch_action(state, target, action)
|
self._dispatch_action(state, target, action)
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
|
|
||||||
@@ -2437,6 +2461,26 @@ class CombatEngine:
|
|||||||
self._log(f"round {self._current_round} {state.combatant.id}: {kind}")
|
self._log(f"round {self._current_round} {state.combatant.id}: {kind}")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED)
|
||||||
|
|
||||||
|
def _execute_ready(self, state: CombatantState, action: Action) -> ActionResult:
|
||||||
|
"""Execute the ready action (PF1e): spend the standard action preparing a
|
||||||
|
payload that fires off-turn when its trigger event occurs.
|
||||||
|
|
||||||
|
The payload is derived from the carrier fields like
|
||||||
|
``_execute_immediate`` (spell / maneuver / attack on ``target_id``;
|
||||||
|
class-ability payloads are out of scope for U4). A new ready replaces
|
||||||
|
any pending one. The engine fires it through ``_check_readied``, then
|
||||||
|
the readied action is consumed. The turn loop keeps running (a policy
|
||||||
|
may still spend the move action).
|
||||||
|
"""
|
||||||
|
kind = action.kind
|
||||||
|
state.readied = _ready_payload(action)
|
||||||
|
state.readied_trigger = action.ready_trigger
|
||||||
|
label = action.ready_trigger or "no trigger"
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: {kind} ({label})"
|
||||||
|
)
|
||||||
|
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
|
||||||
|
|
||||||
def _dispatch_action(
|
def _dispatch_action(
|
||||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2536,13 +2580,20 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _check_reactions(self, event: ReactionEvent) -> None:
|
def _check_reactions(self, event: ReactionEvent) -> None:
|
||||||
"""Offer an event to the reaction policy; execute the first immediate.
|
"""Fire readied actions first, then offer the event to the reaction policy.
|
||||||
|
|
||||||
One immediate per event: the first active combatant (in state order)
|
A pending readied action fires independently of ``reaction_policy``;
|
||||||
whose policy returns an action gets to act. Reactions never cascade —
|
at most one trigger resolves per event (a fired readied action
|
||||||
while an immediate is executing, further events are ignored.
|
suppresses the policy loop). One immediate per event: the first active
|
||||||
|
combatant (in state order) whose policy returns an action gets to act.
|
||||||
|
Reactions never cascade — while a reaction is executing, further
|
||||||
|
events are ignored.
|
||||||
"""
|
"""
|
||||||
if self._reaction_policy is None or self._in_reaction:
|
if self._in_reaction:
|
||||||
|
return
|
||||||
|
if self._check_readied(event):
|
||||||
|
return
|
||||||
|
if self._reaction_policy is None:
|
||||||
return
|
return
|
||||||
for state in self._states:
|
for state in self._states:
|
||||||
if not state.active or state.immediate_used:
|
if not state.active or state.immediate_used:
|
||||||
@@ -2555,6 +2606,66 @@ class CombatEngine:
|
|||||||
self._in_reaction = False
|
self._in_reaction = False
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _check_readied(self, event: ReactionEvent) -> bool:
|
||||||
|
"""Fire the first matching readied action; True when one fired.
|
||||||
|
|
||||||
|
Any active combatant (in state order) whose pending trigger matches
|
||||||
|
the event executes its payload off-turn with no swift cost. The
|
||||||
|
pending action is cleared BEFORE the payload runs, so a readied
|
||||||
|
action is one-shot and never re-fires on its own events; the payload's
|
||||||
|
own events are additionally suppressed by ``_in_reaction``.
|
||||||
|
"""
|
||||||
|
for state in self._states:
|
||||||
|
payload = state.readied
|
||||||
|
if not state.active or payload is None:
|
||||||
|
continue
|
||||||
|
if not self._ready_trigger_matches(state, event):
|
||||||
|
continue
|
||||||
|
trigger = state.readied_trigger
|
||||||
|
state.readied = None
|
||||||
|
state.readied_trigger = None
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
|
f"ready fires ({trigger})"
|
||||||
|
)
|
||||||
|
target = next(
|
||||||
|
(s for s in self._states if s.combatant.id == payload.target_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
self._in_reaction = True
|
||||||
|
self._dispatch_action(state, target, payload)
|
||||||
|
self._in_reaction = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _ready_trigger_matches(
|
||||||
|
self, state: CombatantState, event: ReactionEvent
|
||||||
|
) -> bool:
|
||||||
|
"""True when the event matches the combatant's pending readied trigger.
|
||||||
|
|
||||||
|
Firing requires the event actor to be an ENEMY of the readier
|
||||||
|
(different side); ``enemy_hits_ally`` additionally requires the
|
||||||
|
damage target to share the readier's side. ``enemy_enters_reach``
|
||||||
|
fires on any enemy movement step — documented simplification: reach
|
||||||
|
is not re-computed per step.
|
||||||
|
"""
|
||||||
|
trigger = state.readied_trigger
|
||||||
|
if trigger is None:
|
||||||
|
return False
|
||||||
|
hostile = event.actor.side != state.side
|
||||||
|
if trigger == "enemy_enters_reach":
|
||||||
|
return hostile and event.kind == "movement_step"
|
||||||
|
if trigger == "enemy_casts":
|
||||||
|
return hostile and event.kind == "spell_cast"
|
||||||
|
if trigger == "enemy_attacks":
|
||||||
|
return hostile and event.kind == "attack_resolved"
|
||||||
|
return (
|
||||||
|
hostile
|
||||||
|
and event.kind == "damage_taken"
|
||||||
|
and event.target is not None
|
||||||
|
and event.target.side == state.side
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_cast_spell(
|
def _execute_cast_spell(
|
||||||
self,
|
self,
|
||||||
state: CombatantState,
|
state: CombatantState,
|
||||||
@@ -4259,6 +4370,30 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_payload(action: Action) -> Action:
|
||||||
|
"""Derive the concrete payload of a ready action from its carrier fields.
|
||||||
|
|
||||||
|
Mirrors ``_execute_immediate``: ``spell_name`` casts, ``maneuver``
|
||||||
|
performs the maneuver (carrying direction/trick_condition), anything
|
||||||
|
else attacks ``target_id``. Class-ability payloads are out of scope for U4.
|
||||||
|
"""
|
||||||
|
if action.spell_name is not None:
|
||||||
|
return Action(
|
||||||
|
kind="cast_spell",
|
||||||
|
target_id=action.target_id,
|
||||||
|
spell_name=action.spell_name,
|
||||||
|
)
|
||||||
|
if action.maneuver is not None:
|
||||||
|
return Action(
|
||||||
|
kind="maneuver",
|
||||||
|
target_id=action.target_id,
|
||||||
|
maneuver=action.maneuver,
|
||||||
|
direction=action.direction,
|
||||||
|
trick_condition=action.trick_condition,
|
||||||
|
)
|
||||||
|
return Action(kind="attack", target_id=action.target_id)
|
||||||
|
|
||||||
|
|
||||||
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""The ready action (Package U4).
|
||||||
|
|
||||||
|
A combatant spends its standard action preparing a payload (attack, cast_spell
|
||||||
|
or maneuver, encoded via the Action carrier fields like the immediate action).
|
||||||
|
When a matching ``ReactionEvent`` from an ENEMY occurs, the engine fires the
|
||||||
|
payload off-turn through the reaction machinery — no swift cost — and the
|
||||||
|
readied action is consumed (one-shot). Triggers: ``enemy_enters_reach`` ←
|
||||||
|
movement_step, ``enemy_casts`` ← spell_cast, ``enemy_attacks`` ←
|
||||||
|
attack_resolved, ``enemy_hits_ally`` ← damage_taken.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
Action,
|
||||||
|
ActionResult,
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import DamageEffect, SpellSpec
|
||||||
|
from tests.test_observation_enriched import _snapshot, make_grid, make_state
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(id_: str, *, spells: tuple[str, ...] = ()) -> Combatant:
|
||||||
|
return Combatant(
|
||||||
|
id=id_,
|
||||||
|
name=id_,
|
||||||
|
level=1,
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=10, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
hp_max=100,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=[
|
||||||
|
AttackSpec(
|
||||||
|
id=f"{id_}-sword",
|
||||||
|
name="short sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=5,
|
||||||
|
damage=[
|
||||||
|
DamageComponent(formula=parse_dice("1d6"), types=["slashing"])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
spells=list(spells),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_BOLT = SpellSpec(
|
||||||
|
name="Bolt",
|
||||||
|
level=1,
|
||||||
|
school="evocation",
|
||||||
|
range="close",
|
||||||
|
save="none",
|
||||||
|
effects=(DamageEffect(formula=parse_dice("1d6"), types=("force",)),),
|
||||||
|
)
|
||||||
|
|
||||||
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||||
|
|
||||||
|
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
|
||||||
|
|
||||||
|
|
||||||
|
def _counting(decide: Callable[[str, int], tuple[Action, ...]]) -> PolicyFn:
|
||||||
|
calls: dict[str, int] = {}
|
||||||
|
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
cid = state.combatant.id
|
||||||
|
n = calls.get(cid, 0)
|
||||||
|
calls[cid] = n + 1
|
||||||
|
return decide(cid, n)
|
||||||
|
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(rng: list[int], policy: PolicyFn, states: list[CombatantState]) -> CombatEngine:
|
||||||
|
return CombatEngine(ScriptedRng(rng), make_grid(), states, policy=policy, round_cap=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_hero() -> tuple[Action, ...]:
|
||||||
|
return (
|
||||||
|
Action(kind="ready", ready_trigger="enemy_enters_reach", target_id="foe"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order(transcript: tuple[str, ...], *needles: str) -> tuple[int, ...]:
|
||||||
|
"""Indices of the first transcript line containing each needle."""
|
||||||
|
return tuple(
|
||||||
|
next(i for i, line in enumerate(transcript) if needle in line)
|
||||||
|
for needle in needles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) readied attack fires when an enemy moves (movement_step) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_attack_fires_on_enemy_movement() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
foe = make_state(_combatant("foe"), "monsters", (0, 2))
|
||||||
|
eng = _engine(
|
||||||
|
# init hero 10, init foe 5, readied attack d20=10 (+5 ≥ AC 10 HIT), dmg 3.
|
||||||
|
[10, 5, 10, 3],
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
_ready_hero()
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (Action(kind="move", target_id="hero"),)
|
||||||
|
if (cid, n) == ("foe", 0)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, foe],
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
assert "round 1 hero: ready (enemy_enters_reach)" in t
|
||||||
|
assert "round 1 hero: ready fires (enemy_enters_reach)" in t
|
||||||
|
attack = "round 1 hero: short sword vs foe d20=10+5=15 AC 10 -> HIT 3 damage (100->97)"
|
||||||
|
assert attack in t
|
||||||
|
# Off-turn: the attack resolves DURING the foe's move, before its log line.
|
||||||
|
idx = _order(
|
||||||
|
t,
|
||||||
|
"round 1 hero: ready (enemy_enters_reach)",
|
||||||
|
"round 1 hero: ready fires (enemy_enters_reach)",
|
||||||
|
attack,
|
||||||
|
"round 1 foe: move (0,2)->(0,1)",
|
||||||
|
)
|
||||||
|
assert idx == tuple(sorted(idx))
|
||||||
|
# Consumed: the readied action is gone after firing.
|
||||||
|
assert hero.readied is None
|
||||||
|
assert hero.readied_trigger is None
|
||||||
|
assert foe.hp == 97
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) readied cast_spell fires on an enemy spell_cast ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_cast_fires_on_enemy_spell_cast() -> None:
|
||||||
|
hero = make_state(_combatant("hero", spells=("Bolt",)), "players", (0, 0))
|
||||||
|
hero.spell_slots_left = {1: 1}
|
||||||
|
foe = make_state(_combatant("foe", spells=("Bolt",)), "monsters", (0, 2))
|
||||||
|
foe.spell_slots_left = {1: 1}
|
||||||
|
eng = CombatEngine(
|
||||||
|
# init hero 10, init foe 5, foe's Bolt dmg 4, readied Bolt dmg 2.
|
||||||
|
ScriptedRng([10, 5, 4, 2]),
|
||||||
|
make_grid(),
|
||||||
|
[hero, foe],
|
||||||
|
spell_registry={"Bolt": _BOLT},
|
||||||
|
policy=_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
(
|
||||||
|
Action(
|
||||||
|
kind="ready",
|
||||||
|
ready_trigger="enemy_casts",
|
||||||
|
target_id="foe",
|
||||||
|
spell_name="Bolt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (Action(kind="cast_spell", target_id="hero", spell_name="Bolt"),)
|
||||||
|
if (cid, n) == ("foe", 0)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
round_cap=2,
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
assert "round 1 hero: ready (enemy_casts)" in t
|
||||||
|
assert any("round 1 foe: cast Bolt vs hero" in line for line in t)
|
||||||
|
assert "round 1 hero: ready fires (enemy_casts)" in t
|
||||||
|
assert any("round 1 hero: cast Bolt vs foe" in line for line in t)
|
||||||
|
idx = _order(
|
||||||
|
t,
|
||||||
|
"round 1 foe: cast Bolt vs hero",
|
||||||
|
"round 1 hero: ready fires (enemy_casts)",
|
||||||
|
"round 1 hero: cast Bolt vs foe",
|
||||||
|
)
|
||||||
|
assert idx == tuple(sorted(idx))
|
||||||
|
assert hero.hp == 96
|
||||||
|
assert foe.hp == 98
|
||||||
|
# The readied cast spent hero's own slot and was consumed.
|
||||||
|
assert hero.spell_slots_left == {1: 0}
|
||||||
|
assert hero.readied is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c) no fire on a non-matching trigger nor on a friendly event ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_ignores_non_matching_trigger() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
foe = make_state(_combatant("foe"), "monsters", (0, 2))
|
||||||
|
eng = _engine(
|
||||||
|
# init hero 10, init foe 5, foe's attack d20=2 (+5=7 < AC 10 MISS).
|
||||||
|
[10, 5, 2],
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
(
|
||||||
|
Action(
|
||||||
|
kind="ready",
|
||||||
|
ready_trigger="enemy_casts",
|
||||||
|
target_id="foe",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (
|
||||||
|
Action(kind="move", target_id="hero"),
|
||||||
|
Action(kind="attack", target_id="hero"),
|
||||||
|
)
|
||||||
|
if (cid, n) == ("foe", 0)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, foe],
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
# movement_step AND attack_resolved: neither matches enemy_casts.
|
||||||
|
assert "round 1 foe: move (0,2)->(0,1)" in t
|
||||||
|
assert "round 1 foe: short sword vs hero d20=2+5=7 AC 10 -> MISS" in t
|
||||||
|
assert not any("ready fires" in line for line in t)
|
||||||
|
assert hero.readied is not None
|
||||||
|
assert hero.readied_trigger == "enemy_casts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_ignores_friendly_movement() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
ally = make_state(_combatant("ally"), "players", (2, 0))
|
||||||
|
foe = make_state(_combatant("foe"), "monsters", (0, 4))
|
||||||
|
eng = _engine(
|
||||||
|
[10, 9, 5], # initiative only — nothing ever attacks.
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
_ready_hero()
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (Action(kind="move", target_id="hero"),)
|
||||||
|
if (cid, n) == ("ally", 0)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, ally, foe],
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
# The ally's movement_step is a friendly event: the ready holds.
|
||||||
|
assert "round 1 ally: move (2,0)->(1,0)" in t
|
||||||
|
assert not any("ready fires" in line for line in t)
|
||||||
|
assert hero.readied is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d) the readied action is consumed (one-shot) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_action_fires_only_once() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
|
||||||
|
v2 = make_state(_combatant("v2"), "monsters", (2, 2))
|
||||||
|
eng = _engine(
|
||||||
|
# init hero 10, v1 5, v2 4; readied attack on v1: d20=10 HIT, dmg 3.
|
||||||
|
[10, 5, 4, 10, 3],
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
(
|
||||||
|
Action(
|
||||||
|
kind="ready",
|
||||||
|
ready_trigger="enemy_enters_reach",
|
||||||
|
target_id="v1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (Action(kind="move", target_id="hero"),)
|
||||||
|
if cid in ("v1", "v2") and n == 0
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, v1, v2],
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
assert sum(1 for line in t if "ready fires" in line) == 1
|
||||||
|
assert "round 1 hero: short sword vs v1 d20=10+5=15 AC 10 -> HIT 3 damage (100->97)" in t
|
||||||
|
# v2's later movement_step is a second matching event: no second fire.
|
||||||
|
assert "round 1 v2: move (2,2)->(1,1)" in t
|
||||||
|
assert not any("vs v2" in line for line in t)
|
||||||
|
assert hero.readied is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── (e) the snapshot exposes readied = {kind, trigger}, None after firing ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_exposes_readied_until_fired() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
foe = make_state(_combatant("foe"), "monsters", (0, 2))
|
||||||
|
eng = _engine(
|
||||||
|
[10, 5, 10, 3],
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
_ready_hero()
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (Action(kind="move", target_id="hero"),)
|
||||||
|
if (cid, n) == ("foe", 0)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, foe],
|
||||||
|
)
|
||||||
|
eng._roll_initiative()
|
||||||
|
eng._take_turn(hero)
|
||||||
|
|
||||||
|
assert hero.last_action_results == [
|
||||||
|
ActionResult(kind="ready", ok=True, reason="ready")
|
||||||
|
]
|
||||||
|
snap = _snapshot(eng, hero)["combatants"][0]
|
||||||
|
assert snap["readied"] == {"kind": "attack", "trigger": "enemy_enters_reach"}
|
||||||
|
|
||||||
|
eng._take_turn(foe) # the foe's move fires the readied attack.
|
||||||
|
snap_after = _snapshot(eng, hero)["combatants"][0]
|
||||||
|
assert snap_after["readied"] is None
|
||||||
|
assert hero.readied is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── (f) no cascade: a fired readied action cannot trigger another ready ───────
|
||||||
|
|
||||||
|
|
||||||
|
def test_readied_payload_events_do_not_cascade() -> None:
|
||||||
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
|
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
|
||||||
|
eng = _engine(
|
||||||
|
# init hero 10, v1 5; round 2 readied attack: d20=10 HIT, dmg 3.
|
||||||
|
[10, 5, 10, 3],
|
||||||
|
_counting(
|
||||||
|
lambda cid, n: (
|
||||||
|
_ready_hero()
|
||||||
|
if (cid, n) == ("hero", 0)
|
||||||
|
else (
|
||||||
|
Action(kind="ready", ready_trigger="enemy_attacks", target_id="hero"),
|
||||||
|
)
|
||||||
|
if (cid, n) == ("v1", 0)
|
||||||
|
else (Action(kind="move", target_id="hero"),)
|
||||||
|
if (cid, n) == ("v1", 1)
|
||||||
|
else _WAIT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[hero, v1],
|
||||||
|
)
|
||||||
|
result = eng.run()
|
||||||
|
t = result.transcript
|
||||||
|
|
||||||
|
# Round 2: v1's move fires hero's readied attack; the attack_resolved
|
||||||
|
# event it generates must NOT fire v1's own readied counter-attack.
|
||||||
|
assert "round 2 hero: ready fires (enemy_enters_reach)" in t
|
||||||
|
assert sum(1 for line in t if "ready fires" in line) == 1
|
||||||
|
assert not any("round 2 v1: short sword vs hero" in line for line in t)
|
||||||
|
assert hero.hp == 100
|
||||||
|
assert v1.readied is not None
|
||||||
|
assert v1.readied_trigger == "enemy_attacks"
|
||||||
Reference in New Issue
Block a user