feat(combat): add stand up and drop prone

This commit is contained in:
2026-09-10 13:07:58 +02:00
parent ebdff4f7e5
commit 468c22ff57
3 changed files with 392 additions and 8 deletions
+65 -7
View File
@@ -537,7 +537,10 @@ class Action:
``ReactionEvent``; the readied action is then consumed.
``total_defense`` and ``fight_defensively`` are standard defensive actions
granting a temporary dodge AC bonus until the combatant's next turn;
``fight_defensively`` also takes -4 on its attacks.
``fight_defensively`` also takes -4 on its attacks. ``stand_up`` is a move
action ending the prone condition (it provokes an AoO from each enemy
threatening the combatant); ``drop_prone`` is a free action going prone
until the combatant stands up.
"""
kind: Literal[
@@ -546,7 +549,7 @@ class Action:
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
"inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "ready",
"total_defense", "fight_defensively",
"total_defense", "fight_defensively", "stand_up", "drop_prone",
]
target_id: str | None = None
spell_name: str | None = None
@@ -567,11 +570,13 @@ class Action:
# 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. U4 adds ready for the ready action.
# U5b adds not_prone / already_prone for the prone actions.
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", "ready",
"not_prone", "already_prone",
})
_REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
@@ -582,6 +587,8 @@ _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
_REASON_DELAYED = "delayed"
_REASON_ALREADY_DELAYED = "already_delayed"
_REASON_READY = "ready"
_REASON_NOT_PRONE = "not_prone"
_REASON_ALREADY_PRONE = "already_prone"
# Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops).
@@ -2433,8 +2440,8 @@ class CombatEngine:
return self._execute_delay(state, target, action)
if kind == "ready":
return self._execute_ready(state, action)
if kind in ("total_defense", "fight_defensively"):
return self._execute_defensive(state, kind)
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
return self._execute_self_action(state, kind)
self._dispatch_action(state, target, action)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
@@ -2506,11 +2513,15 @@ class CombatEngine:
)
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
def _execute_defensive(self, state: CombatantState, kind: str) -> ActionResult:
"""Dispatch a target-free defensive standard action to its helper."""
def _execute_self_action(self, state: CombatantState, kind: str) -> ActionResult:
"""Dispatch a target-free self-affecting action to its helper."""
if kind == "total_defense":
return self._execute_total_defense(state)
return self._execute_fight_defensively(state)
if kind == "fight_defensively":
return self._execute_fight_defensively(state)
if kind == "stand_up":
return self._execute_stand_up(state)
return self._execute_drop_prone(state)
def _execute_total_defense(self, state: CombatantState) -> ActionResult:
"""Execute the total defense action (PF1e): standard action granting a
@@ -2537,6 +2548,53 @@ class CombatEngine:
)
return ActionResult(kind="fight_defensively", ok=True, reason=_REASON_OK)
def _execute_stand_up(self, state: CombatantState) -> ActionResult:
"""Execute the stand up action (PF1e): move action that ends prone.
Standing up provokes an AoO from each enemy threatening the
combatant's squares (``_check_move_aoo``, the movement machinery);
the AoOs resolve while the combatant is still prone (-4 AC). The
prone condition is then removed from both representations: the
duration-tracked ``active_conditions`` (trip/overrun) and the
duration-less ``conditions`` list (drop_prone). If a provoked AoO
takes the combatant out of the fight, the removal is a formality —
inactive combatants do not act.
"""
kind = "stand_up"
if not self._has_condition_named(state, "prone"):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_NOT_PRONE})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_NOT_PRONE)
self._check_move_aoo(state)
state.active_conditions = [
ac for ac in state.active_conditions if ac.condition.name != "prone"
]
state.conditions = [c for c in state.conditions if c.name != "prone"]
self._log(f"round {self._current_round} {state.combatant.id}: stands up")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _execute_drop_prone(self, state: CombatantState) -> ActionResult:
"""Execute the drop prone action (PF1e): free action that goes prone.
The prone condition is appended to the duration-less ``conditions``
list (trip/overrun use the duration-tracked ``active_conditions``
instead), so it persists until the combatant stands up. Both lists
are read by ``_has_condition_named``, so the prone modifiers
(-4 AC, -4 ranged attacks) apply immediately.
"""
kind = "drop_prone"
if self._has_condition_named(state, "prone"):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_ALREADY_PRONE})"
)
return ActionResult(kind=kind, ok=False, reason=_REASON_ALREADY_PRONE)
state.conditions.append(CONDITIONS_BY_NAME["prone"])
self._log(f"round {self._current_round} {state.combatant.id}: drops prone")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> None: