Files
pf1e-simulator/tests/test_tumble.py
T

260 lines
9.9 KiB
Python

"""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