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
+193 -33
View File
@@ -264,6 +264,14 @@ _CONFUSED_ACT_NORMAL_MAX = 25
_CONFUSED_BABBLE_MAX = 50 _CONFUSED_BABBLE_MAX = 50
_CONFUSED_SELF_DAMAGE_MAX = 75 _CONFUSED_SELF_DAMAGE_MAX = 75
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker _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 _HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious — # PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5 # 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 level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
but must pass a concentration check (DC 15 + 2 x spell level, +4 with 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[ kind: Literal[
@@ -1386,6 +1397,13 @@ class Action:
metamagic: tuple[str, ...] = () metamagic: tuple[str, ...] = ()
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles # B24: cast defensively — no AoO, but a concentration check or the spell fizzles
cast_defensively: bool = False 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, # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
@@ -1471,6 +1489,18 @@ class ActionResult:
detail: str = "" 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) @dataclass(frozen=True)
class ReactionEvent: class ReactionEvent:
"""An off-turn event a ``reaction_policy`` may react to. """An off-turn event a ``reaction_policy`` may react to.
@@ -4408,8 +4438,67 @@ class CombatEngine:
attacker, target, weapon, label="AoO" 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( def _check_move_aoo(
self, mover: CombatantState, *, movement: bool = True self,
mover: CombatantState,
*,
movement: bool = True,
tumble: _TumbleAttempt | None = None,
) -> bool: ) -> bool:
"""Resolve AoOs provoked by leaving any threatened cell of the mover's footprint. """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 ``movement`` the per-round per-opponent dedupe in
``_move_aoo_provoked`` skips a second movement-triggered strike from ``_move_aoo_provoked`` skips a second movement-triggered strike from
the same enemy. Stand-up (``movement=False``) is a separate trigger and the same enemy. Stand-up (``movement=False``) is a separate trigger and
always provokes, subject to the allowance. Returns True if the mover always provokes, subject to the allowance. With a ``tumble`` context
dropped. (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()) provoked = self._move_aoo_provoked.setdefault(mover.combatant.id, set())
for cell in mover.occupied_cells(): for cell in mover.occupied_cells():
@@ -4433,13 +4525,23 @@ class CombatEngine:
continue continue
if movement: if movement:
provoked.add(enemy.combatant.id) 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) self._resolve_aoo(enemy, mover)
if not mover.active: if not mover.active:
return True return True
return False return False
def _check_enter_aoo(self, mover: CombatantState, next_pos: Pos) -> bool: def _fresh_entry_occupants(
"""Resolve AoOs provoked by ENTERING a hostile creature's square. 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 PF1e (SRD "Movement, Position, and Distance" / "Moving Through a
Square"): "You can move through a square occupied by a creature three Square"): "You can move through a square occupied by a creature three
@@ -4448,13 +4550,12 @@ class CombatEngine:
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 ``next_pos`` overlaps a hostile occupant's cells but the current
footprint does not (a fresh entry, not an existing co-occupancy). footprint does not (a fresh entry, not an existing co-occupancy).
Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) keep Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) never
their no-AoO sharing behaviour. Capped by the per-round AoO provoke.
allowance; no per-opponent dedupe (``_move_aoo_provoked`` dedupes
only leaving threatened cells). Returns True if the mover dropped.
""" """
before = mover.occupied_cells() before = mover.occupied_cells()
after = self._footprint_at(next_pos, mover) after = self._footprint_at(next_pos, mover)
occupants: list[CombatantState] = []
for s in self._states: for s in self._states:
if not s.active or s is mover or s.side == mover.side: if not s.active or s is mover or s.side == mover.side:
continue continue
@@ -4463,12 +4564,37 @@ class CombatEngine:
if _can_cooccupy(mover.combatant.size, s.combatant.size): if _can_cooccupy(mover.combatant.size, s.combatant.size):
continue # S4c: sharing a square never provokes continue # S4c: sharing a square never provokes
cells = s.occupied_cells() cells = s.occupied_cells()
if not (after & cells): if not (after & cells) or before & cells:
continue continue
if before & cells: occupants.append(s)
continue # already co-occupying: no fresh entry 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): if self._aoo_used.get(s.combatant.id, 0) >= self._aoo_allowance(s):
continue 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) self._resolve_aoo(s, mover)
if not mover.active: if not mover.active:
return True return True
@@ -5392,10 +5518,12 @@ class CombatEngine:
self._step5(state, target) self._step5(state, target)
elif kind == "move": elif kind == "move":
if action.destination is not None: 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: if target is None:
return None return None
self._move(state, target) self._move(state, target, tumble=action.tumble)
elif kind == "wait": elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif kind == "activate_power_attack": elif kind == "activate_power_attack":
@@ -9019,26 +9147,36 @@ class CombatEngine:
) )
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) 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: if state.moved_this_turn:
return return
if self.has_condition_flag(state, "cant_move"): if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return 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 start = state.pos
actual_path = self._walk_steps(state, path) actual_path = self._walk_steps(state, path, tumble=tumble)
if actual_path: if actual_path:
state.moved_this_turn = True state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) 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}") 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). """Move action to an explicit cell within speed (U5c).
Validated against the Dijkstra cost field from the combatant's position Validated against the Dijkstra cost field from the combatant's position
under its speed budget; walked step-by-step with the same AoO 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" kind = "move"
if state.moved_this_turn: if state.moved_this_turn:
@@ -9046,7 +9184,10 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_move"): if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) 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( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
@@ -9054,8 +9195,10 @@ class CombatEngine:
return ActionResult( return ActionResult(
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
) )
path = self._descend_path(state, destination, friendly_passable=True) path = self._descend_path(
self._walk_steps(state, path) state, destination, friendly_passable=True, speed_factor=factor
)
self._walk_steps(state, path, tumble=tumble)
state.moved_this_turn = True state.moved_this_turn = True
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -9068,13 +9211,13 @@ class CombatEngine:
state: CombatantState, state: CombatantState,
destination: Pos, destination: Pos,
*, *,
speed_factor: int = 1, speed_factor: float = 1.0,
friendly_passable: bool = False, friendly_passable: bool = False,
avoid_difficult: bool = False, avoid_difficult: bool = False,
) -> bool: ) -> bool:
"""True when destination is passable, unoccupied and within this speed. """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 ``friendly_passable`` routes the cost field around allies' squares
(move/run may cross them, SRD Movement); the destination itself must (move/run may cross them, SRD Movement); the destination itself must
still be completely free. ``avoid_difficult`` removes difficult cells still be completely free. ``avoid_difficult`` removes difficult cells
@@ -9112,12 +9255,18 @@ class CombatEngine:
) )
def _walk_steps( def _walk_steps(
self, state: CombatantState, path: list[Pos] self, state: CombatantState, path: list[Pos], *, tumble: bool = False
) -> list[Pos]: ) -> 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] = [] actual_path: list[Pos] = []
attempt = _TumbleAttempt() if tumble else None
for step in path: for step in path:
if self._check_move_aoo(state): if self._check_move_aoo(state, tumble=attempt):
break break
if not state.active: if not state.active:
break break
@@ -9125,7 +9274,7 @@ class CombatEngine:
# occupied by other creatures provoke attacks of opportunity from # occupied by other creatures provoke attacks of opportunity from
# those creatures." — resolved BEFORE the step is committed, so a # those creatures." — resolved BEFORE the step is committed, so a
# mover dropped by the entry AoO never lands on the square. # 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 break
if not state.active: if not state.active:
break break
@@ -9137,27 +9286,38 @@ class CombatEngine:
return actual_path return actual_path
def _move_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]: ) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed. """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, Greedy walk of the anchor over the Dijkstra cost field from the target,
requiring the whole footprint to fit at each step. 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( def _descend_path(
self, self,
state: CombatantState, state: CombatantState,
anchor: Pos, anchor: Pos,
*, *,
speed_factor: int = 1, speed_factor: float = 1.0,
avoid_difficult: bool = False, avoid_difficult: bool = False,
friendly_passable: bool = False, friendly_passable: bool = False,
) -> list[Pos]: ) -> list[Pos]:
"""Greedy walk of the anchor over the cost field from ``anchor``. """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 ``avoid_difficult`` excludes difficult-terrain cells from the walk
(PF1e: a run cannot cross difficult terrain), so the walk stops in (PF1e: a run cannot cross difficult terrain), so the walk stops in
front of the obstacle. ``friendly_passable`` lets the walk CROSS front of the obstacle. ``friendly_passable`` lets the walk CROSS
+259
View File
@@ -0,0 +1,259 @@
"""Acrobatics - Tumble (SRD "Acrobatics" / "Moving Through a Square").
KB rules/pathfinder-unchained/acrobatics-dex-armor-check-penalty.md (Tumble):
"You can attempt an Acrobatics check to avoid provoking an attack of
opportunity when moving through a threatened square. If you fail, your
movement ends and you provoke an attack of opportunity from the threatening
opponent. You move at half speed when you tumble... 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".
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-a-space check requires at least one rank.
The engine exposes this as the opt-in ``Action(kind="move", tumble=True)``:
half speed budget, one Acrobatics check per crossed opponent (DC = CMD, +5 on
a fresh entry into the opponent's own square, +2 per additional opponent
already checked this move), success spends the opportunity without a strike,
failure provokes the strike and ends the movement.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
SkillRank,
)
from pf1e_simulator.rng import ScriptedRng
def _sword(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-sword",
name="short sword",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _combatant(
cid: str,
*,
size: str = "Medium",
dex: int = 10,
cmd: int = 18,
speed: int = 30,
acrobatics: SkillRank | None = None,
) -> Combatant:
skills = {"acrobatics": acrobatics} if acrobatics is not None else {}
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10, dex_score=dex, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=15, touch=10, flat_footed=15),
bab=1,
initiative_mod=0,
speed_land_ft=speed,
saves=Saves(fort=0, ref=0, will=0),
attacks=[_sword(cid)],
cmd=cmd,
skills=skills,
)
def _engine(
rng: list[int],
placements: list[tuple[Combatant, tuple[int, int], str]],
) -> CombatEngine:
states = [
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
for c, pos, side in placements
]
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return CombatEngine(ScriptedRng(rng), Grid.from_spec(spec), states)
_TRAINED = SkillRank(rank=4, class_skill=True)
def _move(eng: CombatEngine, mover: CombatantState, dest: tuple[int, int]) -> None:
eng._current_round = 1
eng._execute(mover, Action(kind="move", destination=dest, tumble=True))
def _tumble_lines(eng: CombatEngine) -> list[str]:
return [ln for ln in eng._transcript if "tumble vs" in ln]
# ── success: the crossed opponent's AoO is avoided ───────────────────────────
def test_tumble_success_avoids_the_aoo() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
eng = _engine([20], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
_move(eng, s, (0, 3))
# 20 + 4 ranks + 2 Dex + 3 class skill = 29 >= DC 18: no strike at all.
assert _tumble_lines(eng) == [
"round 1 hero: tumble vs orc acrobatics d20=20+9=29 DC 18 -> avoided"
]
assert not any("AoO" in ln for ln in eng._transcript)
assert s.pos == (0, 3)
def test_tumble_success_spends_the_opportunity_once_per_move() -> None:
# The crossed cell (0,1) AND (0,2) are both threatened by the orc; the
# one-per-opponent dedupe means exactly ONE check for the whole move.
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
eng = _engine([20], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
_move(eng, s, (0, 3))
assert len(_tumble_lines(eng)) == 1
# ── failure: provoke AND the movement ends ───────────────────────────────────
def test_tumble_failure_provokes_and_ends_the_movement() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
eng = _engine([1, 1, 1], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
_move(eng, s, (0, 3))
lines = _tumble_lines(eng)
assert len(lines) == 1
assert "FAILED (AoO, movement ends)" in lines[0]
# The orc struck (its scripted attack d20=1 + 2 vs AC 15: miss) and the
# mover never advanced past the threatened cell it was leaving.
assert any("AoO vs hero" in ln for ln in eng._transcript)
assert s.pos == (0, 1)
# ── multiple opponents: one check each, +2 DC beyond the first ───────────────
def test_multiple_opponents_escalate_the_dc_by_two() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
a = _combatant("a", cmd=18)
b = _combatant("b", cmd=20)
eng = _engine(
[20, 20],
[
(hero, (0, 0), "players"),
(a, (1, 1), "monsters"),
(b, (1, 2), "monsters"),
],
)
s = eng._states[0]
_move(eng, s, (0, 3))
lines = _tumble_lines(eng)
assert [ln.split("DC ")[1].split(" ")[0] for ln in lines] == ["18", "22"]
assert all("-> avoided" in ln for ln in lines)
assert s.pos == (0, 3)
# ── half speed: the budget is halved, verdict and path agree ─────────────────
def test_tumble_moves_at_half_speed() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
# 30 ft speed: a normal move reaches 6 cells, a tumbling move only 3.
# The un-tumbled move provokes the orc's AoO (d20=15 hits, 1d6=5 damage).
eng = _engine([15, 5], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
eng._current_round = 1
rejected = eng._execute(s, Action(kind="move", destination=(0, 4), tumble=True))
assert not rejected.ok
assert rejected.reason == "unreachable_destination"
assert s.pos == (0, 0)
# Without the flag the same destination is reachable at full speed.
ok = eng._execute(s, Action(kind="move", destination=(0, 4)))
assert ok.ok
assert s.pos == (0, 4)
# ── moving through an enemy's space: DC 5 + CMD, trained only ────────────────
def test_space_entry_check_is_five_plus_cmd_and_escalated() -> None:
hero = _combatant("hero", size="Small", dex=14, speed=60, acrobatics=_TRAINED)
huge = _combatant("huge", size="Huge", cmd=22)
# Huge occupies the 3x3 block rows 1-3 cols 1-3; the Small hero at (0,2)
# walks THROUGH it to (4,2) - the only route inside the 6-cell half-speed
# budget crosses the occupant's squares. Leaving the threatened cell
# (0,2) is the threatened-area check (DC 22); entering the occupant's own
# square is the space-entry check (DC 5 + 22 - the same single opponent,
# so no +2 escalation).
eng = _engine([20, 20], [(hero, (0, 2), "players"), (huge, (1, 1), "monsters")])
s = eng._states[0]
_move(eng, s, (4, 2))
lines = _tumble_lines(eng)
assert [ln.split("DC ")[1].split(" ")[0] for ln in lines] == ["22", "27"]
assert not any("AoO" in ln for ln in eng._transcript)
assert s.pos == (4, 2)
def test_untrained_mover_cannot_tumble_through_a_space() -> None:
hero = _combatant("hero", size="Small", dex=14, speed=60) # no ranks
huge = _combatant("huge", size="Huge", cmd=22)
# d20=20 avoids the threatened-area check (allowed untrained); the entry
# into the Huge's square is NOT attempted (trained only per SRD), so the
# occupant's AoO fires (d20=1: miss) and the movement continues.
eng = _engine(
[20, 1, 1, 1], [(hero, (0, 2), "players"), (huge, (1, 1), "monsters")]
)
s = eng._states[0]
_move(eng, s, (4, 2))
assert len(_tumble_lines(eng)) == 1
assert any("AoO vs hero" in ln for ln in eng._transcript)
assert s.pos == (4, 2)
# ── the flag is opt-in and move-only ─────────────────────────────────────────
def test_no_tumble_flag_means_normal_movement() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
eng = _engine([1, 1], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
eng._current_round = 1
eng._execute(s, Action(kind="move", destination=(0, 3)))
# No check rolled; the plain leaving-the-square AoO provoked as always.
assert _tumble_lines(eng) == []
assert any("AoO vs hero" in ln for ln in eng._transcript)
def test_tumble_flag_on_a_5foot_step_is_ignored_and_rolls_no_dice() -> None:
hero = _combatant("hero", dex=14, acrobatics=_TRAINED)
orc = _combatant("orc", cmd=18)
eng = _engine([], [(hero, (0, 0), "players"), (orc, (1, 2), "monsters")])
s = eng._states[0]
eng._current_round = 1
result = eng._execute(s, Action(kind="5foot_step", destination=(0, 1), tumble=True))
assert result.ok
assert s.pos == (0, 1)
assert eng._transcript # step logged; the empty RNG queue was never touched