feat(combat): add delay action

This commit is contained in:
2026-09-10 12:20:33 +02:00
parent c5cfa78f87
commit f83e4b8016
3 changed files with 393 additions and 8 deletions
+107 -7
View File
@@ -145,6 +145,7 @@ _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
_DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting",
@@ -344,6 +345,11 @@ class CombatantState:
# PREVIOUS turn, assigned at the end of each turn so the Observation built
# at the next policy invocation can expose them.
last_action_results: list[ActionResult] = field(default_factory=list)
# Delay action (not in the frozen data model): set when the combatant took
# the delay action this round; delay_before is the id of the combatant it
# chose to act just before (None = end of the round). Reset at round start.
delayed_this_round: bool = False
delay_before: str | None = None
def usable_attacks(self) -> list[AttackSpec]:
return [
@@ -501,7 +507,9 @@ class Action:
AC), ``5foot_step`` is a free action (one square, no AoO, mutually
exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
no-op. The engine executes a policy-returned sequence per turn.
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.
"""
kind: Literal[
@@ -509,7 +517,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",
"activate_power_attack", "delay",
]
target_id: str | None = None
spell_name: str | None = None
@@ -527,11 +535,13 @@ class Action:
# 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).
# the deeper execution-feedback package (U6b). U3 adds delayed /
# already_delayed for the delay 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",
})
_REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
@@ -539,6 +549,8 @@ _REASON_MISSING_TARGET = "missing_target"
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
_REASON_DELAYED = "delayed"
_REASON_ALREADY_DELAYED = "already_delayed"
# Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops).
@@ -648,7 +660,7 @@ class CombatEngine:
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0
self._aoo_used: dict[str, int] = {}
self._initiative_keys: dict[str, tuple[int, int, int]] = {}
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
self._pending_summons: list[CombatantState] = []
@property
@@ -1005,10 +1017,14 @@ class CombatEngine:
)
actions = kept
before = len(self._transcript)
was_delayed = state.delayed_this_round
for action in actions:
if not state.active:
break
results.append(self._execute(state, action))
if state.delayed_this_round and not was_delayed:
# A fresh delay forfeits the rest of the turn.
break
if self._winner_side() is not None:
break
if state.active and len(self._transcript) == before:
@@ -1112,19 +1128,53 @@ class CombatEngine:
)
def _run_round(self) -> bool:
"""Execute one round; return True if the battle ended."""
"""Execute one round; return True if the battle ended.
The round order is mutable: a combatant that took a fresh delay action
is re-inserted right before its ``delay_before`` target when that
target is still ahead, otherwise at the end of the round.
"""
for state in self._states:
state.delayed_this_round = False
order = self._build_round_order()
for state in order:
i = 0
while i < len(order):
state = order[i]
if not state.active:
self._process_dying(state)
i += 1
continue
was_delayed = state.delayed_this_round
if self._take_turn(state):
return True
if self._drive_pending_summons():
return True
i += 1
if (
state.delayed_this_round
and not was_delayed
and state not in order[i:]
):
self._reinsert_delayed(order, i, state)
self._pending_summons.clear()
return self._winner_side() is not None
def _reinsert_delayed(
self, order: list[CombatantState], start: int, state: CombatantState
) -> None:
"""Put a freshly-delayed combatant back into the round order.
Inserted immediately before its ``delay_before`` target if that target
still has to act (search from ``start``); appended at the end otherwise.
"""
insert_at = len(order)
if state.delay_before is not None:
for j in range(start, len(order)):
if order[j].combatant.id == state.delay_before:
insert_at = j
break
order.insert(insert_at, state)
def _drive_pending_summons(self) -> bool:
"""Give summoned creatures their turn immediately; return True if battle ended."""
while self._pending_summons:
@@ -1874,7 +1924,7 @@ class CombatEngine:
stable sort.
"""
def sort_key(s: CombatantState) -> tuple[int, int, int]:
def sort_key(s: CombatantState) -> tuple[float, int, int]:
key = self._initiative_keys.get(s.combatant.id)
if key is not None:
return key
@@ -2334,9 +2384,59 @@ class CombatEngine:
f"{kind} rejected ({_REASON_MISSING_TARGET})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
if kind == "delay":
return self._execute_delay(state, target, action)
self._dispatch_action(state, target, action)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _execute_delay(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> ActionResult:
"""Execute the delay action (PF1e): forgo this turn, act later this round.
With no target the combatant acts at the end of the round; with one it
acts immediately before it. The new initiative total (target total
minus 0.5, or the minimum total minus 0.5) is written into
``_initiative_keys`` so ``_build_round_order`` keeps the position for
the following rounds. A second delay in the same round is rejected
(``already_delayed``) to prevent an infinite delay loop.
"""
kind = action.kind
if state.delayed_this_round:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_ALREADY_DELAYED})"
)
return ActionResult(
kind=kind, ok=False, reason=_REASON_ALREADY_DELAYED
)
key = self._initiative_keys.get(state.combatant.id)
prior_index = key[2] if key is not None else self._states.index(state)
if target is not None:
target_key = self._initiative_keys.get(target.combatant.id)
target_total = target_key[0] if target_key is not None else 0.0
new_total = target_total - _DELAY_INITIATIVE_STEP
else:
totals = [k[0] for k in self._initiative_keys.values()]
new_total = (
min(totals) - _DELAY_INITIATIVE_STEP if totals else 0.0
)
self._initiative_keys[state.combatant.id] = (
new_total,
state.combatant.initiative_mod,
prior_index,
)
state.delayed_this_round = True
state.delay_before = target.combatant.id if target is not None else None
if target is not None:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} before {target.combatant.id}"
)
else:
self._log(f"round {self._current_round} {state.combatant.id}: {kind}")
return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED)
def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> None: