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:
+193
-33
@@ -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
|
||||||
|
|||||||
@@ -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