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:
+194
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user