fix(combat): resolve touch attacks against touch AC and firearms
This commit is contained in:
@@ -114,6 +114,7 @@ _NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
||||
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
||||
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
||||
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
||||
_MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments
|
||||
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
||||
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
|
||||
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
||||
@@ -510,17 +511,31 @@ class CombatEngine:
|
||||
for weapon in attacker.usable_attacks():
|
||||
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
||||
return weapon
|
||||
max_increments = (
|
||||
_MAX_TOUCH_RANGE_INCREMENTS
|
||||
if weapon.kind == "touch"
|
||||
else _MAX_RANGE_INCREMENTS
|
||||
)
|
||||
if (
|
||||
weapon.kind == "ranged"
|
||||
weapon.kind in ("ranged", "touch")
|
||||
and weapon.range_increment_ft is not None
|
||||
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
|
||||
and dist_ft <= weapon.range_increment_ft * max_increments
|
||||
):
|
||||
return weapon
|
||||
return None
|
||||
|
||||
def _is_ranged_attack(self, weapon: AttackSpec) -> bool:
|
||||
"""Ranged-style attack: projectile, or touch thrown/fired from a distance.
|
||||
|
||||
Melee touch attacks (no range increment) are not ranged-style.
|
||||
"""
|
||||
return weapon.kind == "ranged" or (
|
||||
weapon.kind == "touch" and weapon.range_increment_ft is not None
|
||||
)
|
||||
|
||||
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
|
||||
"""PF1e: -2 per full range increment beyond the first, zero within it."""
|
||||
if weapon.kind != "ranged" or weapon.range_increment_ft is None:
|
||||
if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None:
|
||||
return 0
|
||||
increments = dist_ft // weapon.range_increment_ft
|
||||
if dist_ft % weapon.range_increment_ft != 0:
|
||||
@@ -679,16 +694,34 @@ class CombatEngine:
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||
if weapon.kind == "ranged" and self._has_condition_named(attacker, "prone"):
|
||||
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
|
||||
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
|
||||
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
|
||||
# Check if target denies DEX to AC (flat-footed)
|
||||
if self.has_condition_flag(target, "denies_dex_to_ac"):
|
||||
base_ac = target.combatant.ac.flat_footed
|
||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||
full_ac_base = (
|
||||
target.combatant.ac.flat_footed
|
||||
if denies_dex
|
||||
else target.combatant.ac.total
|
||||
)
|
||||
# Touch resolves vs touch AC; firearms beyond their first increment use full AC.
|
||||
if weapon.kind == "touch":
|
||||
if (
|
||||
weapon.firearm
|
||||
and weapon.range_increment_ft is not None
|
||||
and dist_ft > weapon.range_increment_ft
|
||||
):
|
||||
base_ac = full_ac_base
|
||||
elif denies_dex:
|
||||
base_ac = (
|
||||
target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||
)
|
||||
else:
|
||||
base_ac = target.combatant.ac.touch
|
||||
else:
|
||||
base_ac = target.combatant.ac.total
|
||||
base_ac = full_ac_base
|
||||
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
occupied = frozenset(
|
||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
||||
@@ -697,7 +730,7 @@ class CombatEngine:
|
||||
self._grid,
|
||||
attacker.pos,
|
||||
target.pos,
|
||||
ranged=weapon.kind == "ranged",
|
||||
ranged=self._is_ranged_attack(weapon),
|
||||
occupied=occupied,
|
||||
):
|
||||
ac += _COVER_AC_BONUS
|
||||
@@ -900,11 +933,13 @@ class CombatEngine:
|
||||
)
|
||||
|
||||
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
||||
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
|
||||
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
|
||||
|
||||
Ranged-style means projectile weapons and touch thrown/fired weapons
|
||||
(touch with a range increment); melee touch (no increment) does not provoke.
|
||||
Returns True if the attacker dropped (caller must stop attacking).
|
||||
"""
|
||||
if weapon.kind != "ranged":
|
||||
if not self._is_ranged_attack(weapon):
|
||||
return False
|
||||
for enemy in self._enemies_threatening(attacker, attacker.pos):
|
||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||
|
||||
@@ -237,7 +237,9 @@ class AttackSpec(_FrozenModel):
|
||||
|
||||
`count` captures repeated identical swings ("2x Talons") without listing
|
||||
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
||||
`range_increment_ft` to ranged/touch kinds.
|
||||
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
|
||||
weapon (gun) whose shots resolve against touch AC within the first range
|
||||
increment and against full AC beyond it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -251,6 +253,7 @@ class AttackSpec(_FrozenModel):
|
||||
reach_ft: int = 5
|
||||
range_increment_ft: int | None = None
|
||||
count: int = Field(default=1, ge=1)
|
||||
firearm: bool = False
|
||||
|
||||
|
||||
class DamageReduction(_FrozenModel):
|
||||
|
||||
Reference in New Issue
Block a user