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