feat(combat): add ready action with triggers
This commit is contained in:
+145
-10
@@ -13,7 +13,10 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
hook: each resolved attack, movement step, spell cast, and damage application
|
||||
emits a ``ReactionEvent``; the first combatant whose policy returns an
|
||||
``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
|
||||
below).
|
||||
- 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"]
|
||||
|
||||
ReadyTrigger = Literal[
|
||||
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"
|
||||
]
|
||||
|
||||
from pf1e_simulator.los import (
|
||||
cover_bonus_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.
|
||||
delayed_this_round: bool = False
|
||||
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]:
|
||||
return [
|
||||
@@ -509,7 +521,10 @@ class Action:
|
||||
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
|
||||
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[
|
||||
@@ -517,7 +532,7 @@ class Action:
|
||||
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||
"activate_power_attack", "delay",
|
||||
"activate_power_attack", "delay", "ready",
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
@@ -530,18 +545,19 @@ class Action:
|
||||
direction: tuple[int, int] | None = None # steering for directional maneuvers
|
||||
trick_condition: TrickCondition | None = None # dirty trick condition choice
|
||||
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,
|
||||
# missing_target and the downgraded:* reasons; no_weapon, out_of_range,
|
||||
# no_line_of_effect, no_slot and insufficient_resource are declared here for
|
||||
# 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({
|
||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||
"downgraded:staggered", "downgraded:nauseated",
|
||||
"delayed", "already_delayed",
|
||||
"delayed", "already_delayed", "ready",
|
||||
})
|
||||
_REASON_OK = "ok"
|
||||
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
||||
@@ -551,6 +567,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||
_REASON_DELAYED = "delayed"
|
||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||
_REASON_READY = "ready"
|
||||
|
||||
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
||||
# them as explicit rejections instead of silent no-ops).
|
||||
@@ -940,6 +957,11 @@ class CombatEngine:
|
||||
{"kind": r.kind, "ok": r.ok, "reason": r.reason}
|
||||
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(
|
||||
@@ -2386,6 +2408,8 @@ class CombatEngine:
|
||||
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
||||
if kind == "delay":
|
||||
return self._execute_delay(state, target, action)
|
||||
if kind == "ready":
|
||||
return self._execute_ready(state, action)
|
||||
self._dispatch_action(state, target, action)
|
||||
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}")
|
||||
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(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> None:
|
||||
@@ -2536,13 +2580,20 @@ class CombatEngine:
|
||||
)
|
||||
|
||||
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)
|
||||
whose policy returns an action gets to act. Reactions never cascade —
|
||||
while an immediate is executing, further events are ignored.
|
||||
A pending readied action fires independently of ``reaction_policy``;
|
||||
at most one trigger resolves per event (a fired readied action
|
||||
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
|
||||
for state in self._states:
|
||||
if not state.active or state.immediate_used:
|
||||
@@ -2555,6 +2606,66 @@ class CombatEngine:
|
||||
self._in_reaction = False
|
||||
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(
|
||||
self,
|
||||
state: CombatantState,
|
||||
@@ -4259,6 +4370,30 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
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, ...]]
|
||||
|
||||
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
||||
|
||||
Reference in New Issue
Block a user