feat(combat): add total defense and fighting defensively
This commit is contained in:
@@ -153,6 +153,9 @@ _IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the thre
|
||||
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
|
||||
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
|
||||
_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
|
||||
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||
"mind_affecting",
|
||||
@@ -362,6 +365,13 @@ class CombatantState:
|
||||
# fires it. Cleared when the readied action executes (one-shot).
|
||||
readied: Action | None = None
|
||||
readied_trigger: ReadyTrigger | None = None
|
||||
# Defensive actions (not in the frozen data model): the dodge AC bonus
|
||||
# granted by total defense (+4) and fighting defensively (+2), and the
|
||||
# fighting-defensively flag (-4 on attack and crit confirm). Both reset
|
||||
# at the start of the combatant's NEXT turn, so they last through the
|
||||
# opponents' turns in between.
|
||||
defensive_ac_bonus: int = 0
|
||||
fight_defensively: bool = False
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
return [
|
||||
@@ -525,6 +535,9 @@ class Action:
|
||||
action that prepares the carrier-field payload (spell / maneuver / attack
|
||||
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
|
||||
``ReactionEvent``; the readied action is then consumed.
|
||||
``total_defense`` and ``fight_defensively`` are standard defensive actions
|
||||
granting a temporary dodge AC bonus until the combatant's next turn;
|
||||
``fight_defensively`` also takes -4 on its attacks.
|
||||
"""
|
||||
|
||||
kind: Literal[
|
||||
@@ -533,6 +546,7 @@ class Action:
|
||||
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||
"activate_power_attack", "delay", "ready",
|
||||
"total_defense", "fight_defensively",
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
@@ -840,6 +854,8 @@ class CombatEngine:
|
||||
"grappled_with": state.grappled_with,
|
||||
"moved_this_turn": state.moved_this_turn,
|
||||
"power_attack": state.power_attack,
|
||||
"defensive_ac_bonus": state.defensive_ac_bonus,
|
||||
"fight_defensively": state.fight_defensively,
|
||||
"name": state.combatant.name,
|
||||
"level": state.combatant.level,
|
||||
"size": state.combatant.size,
|
||||
@@ -1013,6 +1029,8 @@ class CombatEngine:
|
||||
self._apply_fear_aura(state)
|
||||
state.moved_this_turn = False
|
||||
state.power_attack = False
|
||||
state.defensive_ac_bonus = 0
|
||||
state.fight_defensively = False
|
||||
original = self._policy(self, state)
|
||||
actions = original
|
||||
results: list[ActionResult] = []
|
||||
@@ -1540,9 +1558,12 @@ class CombatEngine:
|
||||
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
||||
fd_penalty = (
|
||||
_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0
|
||||
)
|
||||
total = (
|
||||
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
||||
+ fim_penalty
|
||||
+ fim_penalty + fd_penalty
|
||||
)
|
||||
# Check if target denies DEX to AC (flat-footed)
|
||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||
@@ -1568,6 +1589,7 @@ class CombatEngine:
|
||||
else:
|
||||
base_ac = full_ac_base
|
||||
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
ac += target.defensive_ac_bonus
|
||||
occupied = frozenset(
|
||||
cell
|
||||
for s in self._states
|
||||
@@ -1639,6 +1661,7 @@ class CombatEngine:
|
||||
confirm_total = (
|
||||
confirm + base_bonus + penalty + flank + high_ground
|
||||
+ attack_mod - pa_amt + fim_penalty
|
||||
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
||||
)
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
concealment = self._concealment_chance(target)
|
||||
@@ -2410,6 +2433,8 @@ class CombatEngine:
|
||||
return self._execute_delay(state, target, action)
|
||||
if kind == "ready":
|
||||
return self._execute_ready(state, action)
|
||||
if kind in ("total_defense", "fight_defensively"):
|
||||
return self._execute_defensive(state, kind)
|
||||
self._dispatch_action(state, target, action)
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||
|
||||
@@ -2481,6 +2506,37 @@ class CombatEngine:
|
||||
)
|
||||
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
|
||||
|
||||
def _execute_defensive(self, state: CombatantState, kind: str) -> ActionResult:
|
||||
"""Dispatch a target-free defensive standard action to its helper."""
|
||||
if kind == "total_defense":
|
||||
return self._execute_total_defense(state)
|
||||
return self._execute_fight_defensively(state)
|
||||
|
||||
def _execute_total_defense(self, state: CombatantState) -> ActionResult:
|
||||
"""Execute the total defense action (PF1e): standard action granting a
|
||||
+4 dodge bonus to AC until the start of the combatant's next turn.
|
||||
"""
|
||||
state.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"total defense (+{_TOTAL_DEFENSE_AC_BONUS} AC)"
|
||||
)
|
||||
return ActionResult(kind="total_defense", ok=True, reason=_REASON_OK)
|
||||
|
||||
def _execute_fight_defensively(self, state: CombatantState) -> ActionResult:
|
||||
"""Execute the fighting defensively action (PF1e): standard action
|
||||
granting a +2 dodge bonus to AC until the start of the combatant's
|
||||
next turn, at the cost of -4 on attack and crit confirmation rolls.
|
||||
"""
|
||||
state.fight_defensively = True
|
||||
state.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"fighting defensively ({_FIGHT_DEFENSIVELY_ATTACK_PENALTY} attack, "
|
||||
f"+{_FIGHT_DEFENSIVELY_AC_BONUS} AC)"
|
||||
)
|
||||
return ActionResult(kind="fight_defensively", ok=True, reason=_REASON_OK)
|
||||
|
||||
def _dispatch_action(
|
||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user