feat(rules): Acrobatics TUMBLING opt-in on move - half speed budget, one Acrobatics check per crossed opponent (DC = CMD, +5 fresh entry into its space trained-only, +2 per additional opponent beyond the first), success spends the opportunity without a strike, failure provokes the AoO and ends the movement (SRD Acrobatics Tumble / Moving Through a Square), transcript logs each check, 9 tests

This commit is contained in:
2026-09-20 21:06:12 +02:00
parent e0fb5beae2
commit 61686b5f4e
2 changed files with 453 additions and 34 deletions
+194 -34
View File
@@ -264,6 +264,14 @@ _CONFUSED_ACT_NORMAL_MAX = 25
_CONFUSED_BABBLE_MAX = 50
_CONFUSED_SELF_DAMAGE_MAX = 75
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker
# PF1e (SRD "Acrobatics" - Tumble, KB rules/pathfinder-unchained/
# acrobatics-dex-armor-check-penalty.md): "Move through an enemy's space:
# 5 + opponent's CMD" and "The DC increases by 2 for each opponent beyond
# the first."
_TUMBLE_SPACE_ENTRY_DC = 5
_TUMBLE_EXTRA_OPPONENT_DC = 2
# SRD "Acrobatics" - Tumble: "You move at half speed when you tumble".
_TUMBLE_SPEED_FACTOR = 0.5
_HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5
@@ -1348,7 +1356,10 @@ class Action:
level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
but must pass a concentration check (DC 15 + 2 x spell level, +4 with
Combat Casting) or the spell fizzles.
Combat Casting) or the spell fizzles. ``move`` accepts ``tumble``: the
move happens at half speed and each opponent crossed is passed with one
Acrobatics check against its CMD (SRD "Acrobatics" - Tumble; see the
``tumble`` field comment).
"""
kind: Literal[
@@ -1386,6 +1397,13 @@ class Action:
metamagic: tuple[str, ...] = ()
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles
cast_defensively: bool = False
# Acrobatics tumble (SRD "Acrobatics" — Tumble): opt-in on `move` only.
# The move spends half its speed budget and, against each opponent whose
# threatened area is crossed, attempts one Acrobatics check (DC = that
# opponent's CMD, +5 to pass through its own space, +2 per additional
# opponent already tumbled past this move): success avoids its AoO,
# failure provokes it AND ends the movement (see _tumble_avoids).
tumble: bool = False
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
@@ -1471,6 +1489,18 @@ class ActionResult:
detail: str = ""
@dataclass
class _TumbleAttempt:
"""Per-move Acrobatics tumble bookkeeping.
``checked`` holds the opponents an Acrobatics check was already attempted
against during this tumbling move - its size is the "+2 per additional
opponent" DC escalation and it guarantees one check per opponent.
"""
checked: set[str] = field(default_factory=set)
@dataclass(frozen=True)
class ReactionEvent:
"""An off-turn event a ``reaction_policy`` may react to.
@@ -4408,8 +4438,67 @@ class CombatEngine:
attacker, target, weapon, label="AoO"
)
def _tumble_avoids(
self,
mover: CombatantState,
enemy: CombatantState,
attempt: _TumbleAttempt,
*,
space_entry: bool,
) -> bool:
"""One Acrobatics check of a tumbling move against ``enemy``.
PF1e (SRD "Acrobatics" - Tumble, KB rules/pathfinder-unchained/
acrobatics-dex-armor-check-penalty.md): "You can attempt an Acrobatics
check to avoid provoking an attack of opportunity when moving through a
threatened square. [...] If you try to tumble past multiple opponents
in the same round, attempt an Acrobatics check against each of them.
The DC increases by 2 for each opponent beyond the first." DC table:
"Move through a threatened area - Opponent's CMD; Move through an
enemy's space - 5 + Opponent's CMD" - the +5 is the ``space_entry``
case (entering a hostile creature's own square, only possible against
a much larger occupant). One check per opponent per tumble; True =
avoided (no AoO), False = failed (the caller provokes the AoO and ends
the movement - "If you fail, your movement ends and you provoke an
attack of opportunity from the threatening opponent").
"""
extra = _TUMBLE_EXTRA_OPPONENT_DC * (
len(attempt.checked) - (1 if enemy.combatant.id in attempt.checked else 0)
)
dc = (
self._maneuver_cmd(enemy)
+ (_TUMBLE_SPACE_ENTRY_DC if space_entry else 0)
+ extra
)
attempt.checked.add(enemy.combatant.id)
check = self.skill_check(mover, "acrobatics")
success = check.total >= dc
self._log(
f"round {self._current_round} {mover.combatant.id}: tumble vs "
f"{enemy.combatant.id} acrobatics d20={check.roll}"
f"+{check.total - check.roll}={check.total} DC {dc} "
f"-> {'avoided' if success else 'FAILED (AoO, movement ends)'}"
)
return success
def _tumble_trained(self, mover: CombatantState) -> bool:
"""True when the mover may attempt an Acrobatics check at all.
PF1e (SRD "Moving Through a Square", KB rules/prpg-core-rulebook/
moving-through-a-square.md): "A trained character can attempt to use
Acrobatics to move through a square occupied by an opponent" - the
through-an-enemy's-space tumble requires at least one rank; an
untrained mover simply provokes the entry AoO normally.
"""
skill = mover.combatant.skills.get("acrobatics")
return skill is not None and skill.rank > 0
def _check_move_aoo(
self, mover: CombatantState, *, movement: bool = True
self,
mover: CombatantState,
*,
movement: bool = True,
tumble: _TumbleAttempt | None = None,
) -> bool:
"""Resolve AoOs provoked by leaving any threatened cell of the mover's footprint.
@@ -4421,8 +4510,11 @@ class CombatEngine:
``movement`` the per-round per-opponent dedupe in
``_move_aoo_provoked`` skips a second movement-triggered strike from
the same enemy. Stand-up (``movement=False``) is a separate trigger and
always provokes, subject to the allowance. Returns True if the mover
dropped.
always provokes, subject to the allowance. With a ``tumble`` context
(SRD "Acrobatics" - Tumble) each provoking opponent instead faces one
Acrobatics check: a passed check spends the opportunity without a
strike, a failed one provokes the strike AND ends the movement.
Returns True if the mover dropped or the tumble check failed.
"""
provoked = self._move_aoo_provoked.setdefault(mover.combatant.id, set())
for cell in mover.occupied_cells():
@@ -4433,28 +4525,37 @@ class CombatEngine:
continue
if movement:
provoked.add(enemy.combatant.id)
if tumble is not None:
avoided = self._tumble_avoids(
mover, enemy, tumble, space_entry=False
)
if avoided:
continue
self._resolve_aoo(enemy, mover)
return True
self._resolve_aoo(enemy, mover)
if not mover.active:
return True
return False
def _check_enter_aoo(self, mover: CombatantState, next_pos: Pos) -> bool:
"""Resolve AoOs provoked by ENTERING a hostile creature's square.
def _fresh_entry_occupants(
self, mover: CombatantState, next_pos: Pos
) -> list[CombatantState]:
"""Hostile occupants whose square the mover would ENTER at ``next_pos``.
PF1e (SRD "Movement, Position, and Distance" / "Moving Through a
Square"): "You can move through a square occupied by a creature three
size categories larger than you" / "a creature three size categories
smaller than it" — the pass-through provokes an attack of opportunity
from the occupant. The strike fires when the mover's footprint at
from the occupant. The strike fires when the mover's footprint at
``next_pos`` overlaps a hostile occupant's cells but the current
footprint does not (a fresh entry, not an existing co-occupancy).
Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) keep
their no-AoO sharing behaviour. Capped by the per-round AoO
allowance; no per-opponent dedupe (``_move_aoo_provoked`` dedupes
only leaving threatened cells). Returns True if the mover dropped.
Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) never
provoke.
"""
before = mover.occupied_cells()
after = self._footprint_at(next_pos, mover)
occupants: list[CombatantState] = []
for s in self._states:
if not s.active or s is mover or s.side == mover.side:
continue
@@ -4463,12 +4564,37 @@ class CombatEngine:
if _can_cooccupy(mover.combatant.size, s.combatant.size):
continue # S4c: sharing a square never provokes
cells = s.occupied_cells()
if not (after & cells):
if not (after & cells) or before & cells:
continue
if before & cells:
continue # already co-occupying: no fresh entry
occupants.append(s)
return occupants
def _check_enter_aoo(
self,
mover: CombatantState,
next_pos: Pos,
*,
tumble: _TumbleAttempt | None = None,
) -> bool:
"""Resolve AoOs provoked by ENTERING a hostile creature's square.
Capped by the per-round AoO allowance; no per-opponent dedupe
(``_move_aoo_provoked`` dedupes only leaving threatened cells). With
a ``tumble`` context a trained mover (SRD "Moving Through a Square":
"A TRAINED character can attempt to use Acrobatics to move through a
square occupied by an opponent") attempts the Acrobatics check at
DC 5 + the occupant's CMD; a pass avoids the strike, a failure
provokes it and ends the movement. Returns True if the mover dropped
or the tumble check failed.
"""
for s in self._fresh_entry_occupants(mover, next_pos):
if self._aoo_used.get(s.combatant.id, 0) >= self._aoo_allowance(s):
continue
if tumble is not None and self._tumble_trained(mover):
if self._tumble_avoids(mover, s, tumble, space_entry=True):
continue
self._resolve_aoo(s, mover)
return True
self._resolve_aoo(s, mover)
if not mover.active:
return True
@@ -5392,10 +5518,12 @@ class CombatEngine:
self._step5(state, target)
elif kind == "move":
if action.destination is not None:
return self._move_to(state, action.destination)
return self._move_to(
state, action.destination, tumble=action.tumble
)
if target is None:
return None
self._move(state, target)
self._move(state, target, tumble=action.tumble)
elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif kind == "activate_power_attack":
@@ -9019,26 +9147,36 @@ class CombatEngine:
)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
def _move(self, state: CombatantState, target: CombatantState) -> None:
def _move(
self, state: CombatantState, target: CombatantState, *, tumble: bool = False
) -> None:
if state.moved_this_turn:
return
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return
path = self._move_path(state, target, friendly_passable=True)
# SRD "Acrobatics" - Tumble: "You move at half speed when you tumble".
factor = _TUMBLE_SPEED_FACTOR if tumble else 1.0
path = self._move_path(
state, target, friendly_passable=True, speed_factor=factor
)
start = state.pos
actual_path = self._walk_steps(state, path)
actual_path = self._walk_steps(state, path, tumble=tumble)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_to(self, state: CombatantState, destination: Pos) -> ActionResult:
def _move_to(
self, state: CombatantState, destination: Pos, *, tumble: bool = False
) -> ActionResult:
"""Move action to an explicit cell within speed (U5c).
Validated against the Dijkstra cost field from the combatant's position
under its speed budget; walked step-by-step with the same AoO
machinery as the auto-pathed move.
machinery as the auto-pathed move. With ``tumble`` the budget is half
speed (SRD "Acrobatics" - Tumble) and the reachability verdict uses
the same halved budget as the walked path.
"""
kind = "move"
if state.moved_this_turn:
@@ -9046,7 +9184,10 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
if not self._destination_reachable(state, destination, friendly_passable=True):
factor = _TUMBLE_SPEED_FACTOR if tumble else 1.0
if not self._destination_reachable(
state, destination, friendly_passable=True, speed_factor=factor
):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
@@ -9054,8 +9195,10 @@ class CombatEngine:
return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
)
path = self._descend_path(state, destination, friendly_passable=True)
self._walk_steps(state, path)
path = self._descend_path(
state, destination, friendly_passable=True, speed_factor=factor
)
self._walk_steps(state, path, tumble=tumble)
state.moved_this_turn = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
@@ -9068,13 +9211,13 @@ class CombatEngine:
state: CombatantState,
destination: Pos,
*,
speed_factor: int = 1,
speed_factor: float = 1.0,
friendly_passable: bool = False,
avoid_difficult: bool = False,
) -> bool:
"""True when destination is passable, unoccupied and within this speed.
``speed_factor`` widens the budget (run: 4x speed, U5e).
``speed_factor`` scales the budget (run: 4x speed, U5e; tumble: 0.5x).
``friendly_passable`` routes the cost field around allies' squares
(move/run may cross them, SRD Movement); the destination itself must
still be completely free. ``avoid_difficult`` removes difficult cells
@@ -9112,12 +9255,18 @@ class CombatEngine:
)
def _walk_steps(
self, state: CombatantState, path: list[Pos]
self, state: CombatantState, path: list[Pos], *, tumble: bool = False
) -> list[Pos]:
"""Walk a precomputed path cell by cell, resolving AoOs and reactions."""
"""Walk a precomputed path cell by cell, resolving AoOs and reactions.
With ``tumble`` (SRD "Acrobatics" - Tumble) every movement-AoO
provocation is met with one Acrobatics check per opponent instead of
the strike on a success, and a failed check ends the walk.
"""
actual_path: list[Pos] = []
attempt = _TumbleAttempt() if tumble else None
for step in path:
if self._check_move_aoo(state):
if self._check_move_aoo(state, tumble=attempt):
break
if not state.active:
break
@@ -9125,7 +9274,7 @@ class CombatEngine:
# occupied by other creatures provoke attacks of opportunity from
# those creatures." — resolved BEFORE the step is committed, so a
# mover dropped by the entry AoO never lands on the square.
if self._check_enter_aoo(state, step):
if self._check_enter_aoo(state, step, tumble=attempt):
break
if not state.active:
break
@@ -9137,27 +9286,38 @@ class CombatEngine:
return actual_path
def _move_path(
self, state: CombatantState, target: CombatantState, *, friendly_passable: bool = False
self,
state: CombatantState,
target: CombatantState,
*,
friendly_passable: bool = False,
speed_factor: float = 1.0,
) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed.
Greedy walk of the anchor over the Dijkstra cost field from the target,
requiring the whole footprint to fit at each step.
"""
return self._descend_path(state, target.pos, friendly_passable=friendly_passable)
return self._descend_path(
state,
target.pos,
friendly_passable=friendly_passable,
speed_factor=speed_factor,
)
def _descend_path(
self,
state: CombatantState,
anchor: Pos,
*,
speed_factor: int = 1,
speed_factor: float = 1.0,
avoid_difficult: bool = False,
friendly_passable: bool = False,
) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``.
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
``speed_factor`` scales the movement budget (run: 4x speed, U5e;
tumbling move: 0.5x, SRD "Acrobatics" - Tumble).
``avoid_difficult`` excludes difficult-terrain cells from the walk
(PF1e: a run cannot cross difficult terrain), so the walk stops in
front of the obstacle. ``friendly_passable`` lets the walk CROSS