fix(rules): helpless Dex-0 AC adds exactly -5 on flat-footed base; touch base strips full Dex mod when helpless (no negative-Dex double count)

This commit is contained in:
2026-09-16 21:32:19 +02:00
parent e0bf484394
commit cc9fba3b52
2 changed files with 79 additions and 11 deletions
+14 -8
View File
@@ -2707,9 +2707,11 @@ class CombatEngine:
PF1e (CRB): a prone defender takes -4 AC against melee attacks but PF1e (CRB): a prone defender takes -4 AC against melee attacks but
gains +4 AC against ranged attacks; a helpless defender (paralyzed, gains +4 AC against ranged attacks; a helpless defender (paralyzed,
asleep, unconscious, helpless) has an effective Dexterity score of 0, asleep, unconscious, helpless) has an effective Dexterity score of 0,
so its whole Dex modifier gives way to the Dex-0 penalty (-5) — so its whole Dex modifier gives way to the Dex-0 penalty (-5). The
flat-footed AC already drops a positive Dex bonus, a native Dex flat-footed base already excludes ALL Dex (bonus and penalty alike),
penalty must still be replaced.""" so the mod is exactly -5 there; on the touch base the stored Dex
modifier is stripped at the base (see resolve_attack) before this
-5 is added."""
mods = self._stat_modifiers(target, "ac") mods = self._stat_modifiers(target, "ac")
if self._has_condition_named(target, "prone"): if self._has_condition_named(target, "prone"):
mods.append( mods.append(
@@ -2718,8 +2720,7 @@ class CombatEngine:
) )
) )
if self._is_helpless(target): if self._is_helpless(target):
dex_penalty = min(0, target.combatant.abilities.dex_mod) mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD))
mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD - dex_penalty))
return mods return mods
def resolve_attack( def resolve_attack(
@@ -2804,9 +2805,14 @@ class CombatEngine:
): ):
base_ac = full_ac_base base_ac = full_ac_base
elif denies_dex: elif denies_dex:
base_ac = ( # A flat-footed defender loses only a POSITIVE Dex bonus (a
target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) # Dex penalty stays on touch AC); a HELPLESS defender has an
) # effective Dex score of 0, so its whole stored Dex modifier
# is stripped here and the exact -5 Dex-0 penalty is added by
# _attack_ac_mods.
dex_mod = target.combatant.abilities.dex_mod
strip = dex_mod if self._is_helpless(target) else max(0, dex_mod)
base_ac = target.combatant.ac.touch - strip
else: else:
base_ac = target.combatant.ac.touch base_ac = target.combatant.ac.touch
else: else:
+65 -3
View File
@@ -328,8 +328,9 @@ def test_stunned_is_not_helpless() -> None:
def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None: def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None:
# A Dex 8 (-1) helpless target: the -1 penalty gives way to the Dex-0 -5 # A Dex 8 (-1) helpless target: the flat-footed base already excludes
# (adjustment -5 - (-1) = -4 on top of the flat-footed base). # ALL Dex (bonus and penalty alike), so effective Dex 0 adds exactly -5.
# The old formula added -5 - (-1) = -4, double-counting the Dex penalty.
eng = _engine( eng = _engine(
[_combatant("atk"), _combatant("def", ac=14, dex=8)], [_combatant("atk"), _combatant("def", ac=14, dex=8)],
[(0, 0), (0, 1)], [(0, 0), (0, 1)],
@@ -337,7 +338,68 @@ def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None:
) )
_add_condition(eng._states[1], PARALYZED) _add_condition(eng._states[1], PARALYZED)
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
assert result.ac == 10 # 14 - 4 assert result.ac == 9 # 14 - 5
def _touch(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-touch",
name="touch",
kind="touch",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["cold"])],
)
def test_helpless_positive_dex_melee_ac_is_flat_footed_minus5() -> None:
# Dex 16 (+3): flat-footed base drops the +3 already; Dex-0 adds -5.
eng = _engine(
[_combatant("atk"), _combatant("def", ac=16, dex=16)],
[(0, 0), (0, 1)],
[10, 4],
)
_add_condition(eng._states[1], PARALYZED)
result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk"))
assert result.ac == 11 # 16 - 5
def test_helpless_positive_dex_touch_ac_strips_dex_then_minus5() -> None:
# Dex 16 (+3): touch base 16 includes the +3; effective Dex 0 strips it
# (16 - 3) and adds -5 -> 8.
eng = _engine(
[_combatant("atk"), _combatant("def", ac=16, dex=16)],
[(0, 0), (0, 1)],
[10, 4],
)
_add_condition(eng._states[1], PARALYZED)
result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk"))
assert result.ac == 8
def test_helpless_negative_dex_touch_ac_strips_penalty_then_minus5() -> None:
# Dex 8 (-1): touch base 14 includes the -1; effective Dex 0 replaces it
# (14 + 1) and adds -5 -> 10.
eng = _engine(
[_combatant("atk"), _combatant("def", ac=14, dex=8)],
[(0, 0), (0, 1)],
[10, 4],
)
_add_condition(eng._states[1], PARALYZED)
result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk"))
assert result.ac == 10
def test_flat_footed_negative_dex_touch_keeps_penalty_without_minus5() -> None:
# Control: a merely flat-footed (NOT helpless) Dex 8 target loses only a
# positive Dex bonus — the -1 stays on touch AC and no Dex-0 -5 applies.
eng = _engine(
[_combatant("atk"), _combatant("def", ac=14, dex=8)],
[(0, 0), (0, 1)],
[10, 4],
)
_add_condition(eng._states[1], CONDITIONS_BY_NAME["flat_footed"])
result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk"))
assert result.ac == 14
# ── B6: nauseated — single move action only, no casting ────────────────────── # ── B6: nauseated — single move action only, no casting ──────────────────────