260 lines
9.9 KiB
Python
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
|