feat(combat): add aid another and run actions
This commit is contained in:
+187
-21
@@ -156,6 +156,9 @@ _DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
|
||||
_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC
|
||||
_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
|
||||
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
|
||||
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
|
||||
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
|
||||
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x land speed
|
||||
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||
"mind_affecting",
|
||||
@@ -396,6 +399,14 @@ class CombatantState:
|
||||
# opponents' turns in between.
|
||||
defensive_ac_bonus: int = 0
|
||||
fight_defensively: bool = False
|
||||
# Aid another & run (not in the frozen data model): aid bonuses granted to
|
||||
# this combatant by an ally's aid another — aid_attack_bonus is consumed by
|
||||
# its next attack roll, aid_ac_bonus applies against the next attack that
|
||||
# targets it. Both (and the running flag, which denies the Dex bonus to AC
|
||||
# until the runner's next turn) reset at the start of the combatant's turn.
|
||||
aid_attack_bonus: int = 0
|
||||
aid_ac_bonus: int = 0
|
||||
running: bool = False
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
return [
|
||||
@@ -580,7 +591,15 @@ class Action:
|
||||
outside the map or without line of effect from the caster is rejected
|
||||
(``invalid_destination``); with ``target_pos`` and no resolved target the
|
||||
spell is cast on the point and every creature in the area rolls its own
|
||||
save.
|
||||
save. ``aid_another`` is a standard action helping a resolved ally: the
|
||||
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
|
||||
against AC 10; on success the ally gains +2 on its next attack roll
|
||||
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
||||
targeting it (``aid_mode`` "ac"). ``run`` is a full-round action moving
|
||||
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
|
||||
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
||||
``running``, which lasts until the runner's next turn and denies the
|
||||
runner its Dexterity bonus to AC.
|
||||
"""
|
||||
|
||||
kind: Literal[
|
||||
@@ -590,6 +609,7 @@ class Action:
|
||||
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||
"activate_power_attack", "delay", "ready",
|
||||
"total_defense", "fight_defensively", "stand_up", "drop_prone",
|
||||
"aid_another", "run",
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
@@ -606,6 +626,7 @@ class Action:
|
||||
ready_trigger: ReadyTrigger | None = None # ready action trigger event
|
||||
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
|
||||
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
|
||||
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
|
||||
|
||||
|
||||
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
||||
@@ -618,7 +639,9 @@ class Action:
|
||||
# unreachable_destination for move / 5foot_step with an explicit destination.
|
||||
# U5d adds weapon_unavailable for attack / full_attack with an explicit
|
||||
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
||||
# cast_spell with an explicit target_pos that cannot be honoured.
|
||||
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
||||
# no_weapon (aid another without a usable melee weapon) and
|
||||
# unreachable_destination (run to an unhonourable destination).
|
||||
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||
@@ -650,10 +673,11 @@ _REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
|
||||
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
|
||||
# Action kinds whose helper is a silent no-op (or worse) without a resolved
|
||||
# target; _execute rejects them up front with missing_target instead.
|
||||
# move / 5foot_step are exempt when an explicit destination is provided (U5c).
|
||||
# move / 5foot_step / run are exempt when an explicit destination is provided
|
||||
# (U5c / U5e).
|
||||
_TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({
|
||||
"attack", "full_attack", "charge", "withdraw", "move", "5foot_step",
|
||||
"cast_spell", "maneuver",
|
||||
"cast_spell", "maneuver", "aid_another", "run",
|
||||
})
|
||||
|
||||
|
||||
@@ -919,6 +943,9 @@ class CombatEngine:
|
||||
"power_attack": state.power_attack,
|
||||
"defensive_ac_bonus": state.defensive_ac_bonus,
|
||||
"fight_defensively": state.fight_defensively,
|
||||
"aid_attack_bonus": state.aid_attack_bonus,
|
||||
"aid_ac_bonus": state.aid_ac_bonus,
|
||||
"running": state.running,
|
||||
"aoo_remaining": max(
|
||||
0,
|
||||
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
|
||||
@@ -1145,6 +1172,9 @@ class CombatEngine:
|
||||
state.power_attack = False
|
||||
state.defensive_ac_bonus = 0
|
||||
state.fight_defensively = False
|
||||
state.aid_attack_bonus = 0
|
||||
state.aid_ac_bonus = 0
|
||||
state.running = False
|
||||
original = self._policy(self, state)
|
||||
actions = original
|
||||
results: list[ActionResult] = []
|
||||
@@ -1689,10 +1719,11 @@ class CombatEngine:
|
||||
)
|
||||
total = (
|
||||
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
||||
+ fim_penalty + fd_penalty
|
||||
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
||||
)
|
||||
# Check if target denies DEX to AC (flat-footed)
|
||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||
# Check if target denies DEX to AC (flat-footed); a running target
|
||||
# loses its DEX bonus too (U5e), reusing the same AC path.
|
||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") or target.running
|
||||
full_ac_base = (
|
||||
target.combatant.ac.flat_footed
|
||||
if denies_dex
|
||||
@@ -1716,6 +1747,7 @@ class CombatEngine:
|
||||
base_ac = full_ac_base
|
||||
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
ac += target.defensive_ac_bonus
|
||||
ac += target.aid_ac_bonus
|
||||
occupied = frozenset(
|
||||
cell
|
||||
for s in self._states
|
||||
@@ -1745,6 +1777,7 @@ class CombatEngine:
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
)
|
||||
attacker.aid_attack_bonus = 0
|
||||
return AttackResult(
|
||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
||||
@@ -1786,7 +1819,7 @@ class CombatEngine:
|
||||
confirm = self._rng.d20()
|
||||
confirm_total = (
|
||||
confirm + base_bonus + penalty + flank + high_ground
|
||||
+ attack_mod - pa_amt + fim_penalty
|
||||
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
|
||||
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
||||
)
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
@@ -2552,16 +2585,36 @@ class CombatEngine:
|
||||
rejection = self._target_rejection(state, kind, target, action)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
direct = self._execute_direct(state, target, action)
|
||||
if direct is not None:
|
||||
return direct
|
||||
override = self._dispatch_action(state, target, action)
|
||||
return override if override is not None else ActionResult(
|
||||
kind=kind, ok=True, reason=_REASON_OK
|
||||
)
|
||||
|
||||
def _execute_direct(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> ActionResult | None:
|
||||
"""Execute the kinds with a dedicated (non-target-dispatch) helper.
|
||||
|
||||
delay/ready (U3/U4), the self-affecting defensive and prone actions
|
||||
(U5a/U5b) and the aid another / run pair (U5e) each own an explicit
|
||||
result; None means the action is not one of these kinds and the
|
||||
caller must fall through to ``_dispatch_action``.
|
||||
"""
|
||||
kind = action.kind
|
||||
if kind == "delay":
|
||||
return self._execute_delay(state, target, action)
|
||||
if kind == "ready":
|
||||
return self._execute_ready(state, action)
|
||||
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
|
||||
return self._execute_self_action(state, kind)
|
||||
override = self._dispatch_action(state, target, action)
|
||||
return override if override is not None else ActionResult(
|
||||
kind=kind, ok=True, reason=_REASON_OK
|
||||
)
|
||||
if kind == "aid_another":
|
||||
return self._execute_aid_another(state, target, action)
|
||||
if kind == "run":
|
||||
return self._execute_run(state, target, action)
|
||||
return None
|
||||
|
||||
def _target_rejection(
|
||||
self,
|
||||
@@ -2570,11 +2623,11 @@ class CombatEngine:
|
||||
target: CombatantState | None,
|
||||
action: Action,
|
||||
) -> ActionResult | None:
|
||||
"""U1 missing-target gate; move/5foot_step accept a destination instead (U5c);
|
||||
cast_spell accepts a target_pos instead (U7b)."""
|
||||
"""U1 missing-target gate; move/5foot_step/run accept a destination instead
|
||||
(U5c / U5e); cast_spell accepts a target_pos instead (U7b)."""
|
||||
if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS:
|
||||
return None
|
||||
if kind in ("move", "5foot_step") and action.destination is not None:
|
||||
if kind in ("move", "5foot_step", "run") and action.destination is not None:
|
||||
return None
|
||||
if kind == "cast_spell" and action.target_pos is not None:
|
||||
return None
|
||||
@@ -2734,6 +2787,97 @@ class CombatEngine:
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: drops prone")
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
|
||||
def _execute_aid_another(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> ActionResult:
|
||||
"""Execute the aid another action (PF1e, U5e simplification).
|
||||
|
||||
The aider needs a usable melee weapon and makes an attack roll
|
||||
(d20 + the first usable melee weapon's bonus) against AC 10; natural
|
||||
20 always succeeds and natural 1 always fails (attack-roll
|
||||
convention). On success the target stacks +2 on its next attack roll
|
||||
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
||||
targeting it (``aid_mode`` "ac"); multiple aids add up. Documented
|
||||
simplifications: no adjacency/line-of-effect check on the ally, and
|
||||
any resolved target may be aided (the aider's side is not enforced).
|
||||
"""
|
||||
kind = "aid_another"
|
||||
if target is None:
|
||||
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
||||
weapon = next((w for w in state.usable_attacks() if w.kind == "melee"), None)
|
||||
if weapon is None:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"{kind} rejected ({_REASON_NO_WEAPON})"
|
||||
)
|
||||
return ActionResult(kind=kind, ok=False, reason=_REASON_NO_WEAPON)
|
||||
mode = "ac" if action.aid_mode == "ac" else "attack"
|
||||
roll = self._rng.d20()
|
||||
total = roll + weapon.attack_bonus
|
||||
success = (
|
||||
roll == _NATURAL_TWENTY
|
||||
or (roll != _NATURAL_ONE and total >= _AID_ANOTHER_DC)
|
||||
)
|
||||
if success:
|
||||
if mode == "ac":
|
||||
target.aid_ac_bonus += _AID_ANOTHER_BONUS
|
||||
else:
|
||||
target.aid_attack_bonus += _AID_ANOTHER_BONUS
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"aid another {target.combatant.id} ({mode}) "
|
||||
f"d20={roll}+{weapon.attack_bonus}={total} vs AC {_AID_ANOTHER_DC} "
|
||||
f"-> {'success' if success else 'fail'}"
|
||||
)
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
|
||||
def _execute_run(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> ActionResult:
|
||||
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
|
||||
|
||||
Reuses the move machinery: the Dijkstra descent toward an explicit
|
||||
destination (U5c) or the resolved target, walked step-by-step with
|
||||
the usual AoO provocation, under a 4x speed budget. On completion the
|
||||
combatant is ``running`` until the start of its next turn, which
|
||||
denies it its Dexterity bonus to AC. An explicit destination that
|
||||
cannot be honoured within the 4x budget is rejected with
|
||||
``unreachable_destination``; with neither target nor destination the
|
||||
U1 gate rejects the action with ``missing_target``.
|
||||
"""
|
||||
kind = "run"
|
||||
if state.moved_this_turn:
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
if self.has_condition_flag(state, "cant_move"):
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
if action.destination is not None:
|
||||
reachable = self._destination_reachable(
|
||||
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
|
||||
)
|
||||
if not reachable:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
||||
)
|
||||
return ActionResult(
|
||||
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
||||
)
|
||||
path = self._descend_path(
|
||||
state, action.destination, speed_factor=_RUN_SPEED_FACTOR
|
||||
)
|
||||
self._walk_steps(state, path)
|
||||
state.moved_this_turn = True
|
||||
elif target is not None:
|
||||
path = self._descend_path(
|
||||
state, target.pos, speed_factor=_RUN_SPEED_FACTOR
|
||||
)
|
||||
if self._walk_steps(state, path):
|
||||
state.moved_this_turn = True
|
||||
state.running = True
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: run")
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
|
||||
def _dispatch_action(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> ActionResult | None:
|
||||
@@ -4569,10 +4713,22 @@ class CombatEngine:
|
||||
)
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
|
||||
def _destination_reachable(self, state: CombatantState, destination: Pos) -> bool:
|
||||
"""True when destination is passable, unoccupied and within this speed."""
|
||||
def _destination_reachable(
|
||||
self,
|
||||
state: CombatantState,
|
||||
destination: Pos,
|
||||
*,
|
||||
speed_factor: int = 1,
|
||||
) -> bool:
|
||||
"""True when destination is passable, unoccupied and within this speed.
|
||||
|
||||
``speed_factor`` widens the budget (run: 4x speed, U5e).
|
||||
"""
|
||||
speed_cells = int(
|
||||
state.combatant.speed_land_ft // _SQUARE_FT * self._get_speed_mult(state)
|
||||
state.combatant.speed_land_ft
|
||||
// _SQUARE_FT
|
||||
* self._get_speed_mult(state)
|
||||
* speed_factor
|
||||
)
|
||||
if speed_cells <= 0:
|
||||
return destination == state.pos
|
||||
@@ -4606,10 +4762,20 @@ class CombatEngine:
|
||||
"""
|
||||
return self._descend_path(state, target.pos)
|
||||
|
||||
def _descend_path(self, state: CombatantState, anchor: Pos) -> list[Pos]:
|
||||
"""Greedy walk of the anchor over the cost field from ``anchor``."""
|
||||
def _descend_path(
|
||||
self, state: CombatantState, anchor: Pos, *, speed_factor: int = 1
|
||||
) -> list[Pos]:
|
||||
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
||||
|
||||
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
|
||||
"""
|
||||
speed_mult = self._get_speed_mult(state)
|
||||
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
|
||||
speed_cells = int(
|
||||
state.combatant.speed_land_ft
|
||||
// _SQUARE_FT
|
||||
* speed_mult
|
||||
* speed_factor
|
||||
)
|
||||
if speed_cells <= 0:
|
||||
return []
|
||||
blocked = self._blocked(state)
|
||||
|
||||
Reference in New Issue
Block a user