fix(rules): firing into melee exemption measured target-to-nearest-ally, not attacker-to-target (SRD Shooting into a Melee)

This commit is contained in:
2026-09-16 21:24:48 +02:00
parent e6f7b1988b
commit e0bf484394
3 changed files with 53 additions and 18 deletions
+22 -13
View File
@@ -3056,18 +3056,22 @@ class CombatEngine:
self, attacker: CombatantState, target: CombatantState self, attacker: CombatantState, target: CombatantState
) -> int: ) -> int:
"""PF1e firing into melee: -4 on a ranged attack whose target is """PF1e firing into melee: -4 on a ranged attack whose target is
engaged in melee, avoided entirely (0) when the target or every engaged in melee, avoided entirely (0) when the target stands 10+ ft
threatening opponent stands 10+ ft from the attacker. Precise Shot from every friendly character of the attacker. Precise Shot removes
removes it. it.
PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your
target (or the part of your target you're aiming at, if it's a big target (or the part of your target you're aiming at, if it's a big
target) is at least 10 feet away from the nearest friendly character, target) is at least 10 feet away from the nearest friendly character,
you can avoid the -4 penalty" — the 10+ ft clause is therefore 0, not you can avoid the -4 penalty" — the exemption is measured from the
-2. The -2 applies only when the target is two size categories larger TARGET to the attacker's nearest friendly character (a creature on
than the friendly characters it is engaged with (and 0 at three or the attacker's side other than the attacker), NOT from the attacker's
more): size-category reduction is NOT modeled (documented own position; an archer 30 ft away still takes the -4 when the target
simplification). is locked in melee with one of the archer's allies at 5 ft. The 10+
ft clause is therefore 0, not -2. The -2 applies only when the target
is two size categories larger than the friendly characters it is
engaged with (and 0 at three or more): size-category reduction is
NOT modeled (documented simplification).
The target is engaged when an active enemy of the target (not the The target is engaged when an active enemy of the target (not the
attacker itself) threatens the target's occupied cells with a melee attacker itself) threatens the target's occupied cells with a melee
@@ -3085,12 +3089,17 @@ class CombatEngine:
] ]
if not threats: if not threats:
return 0 return 0
if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT: allies = [
s
for s in self._states
if s is not attacker and s.active and s.side == attacker.side
]
if all(
self.creature_distance(target, ally) * _SQUARE_FT
>= _FIRING_MELEE_SAFE_FT
for ally in allies
):
return 0 return 0
for threat in threats:
dist = self.creature_distance(attacker, threat) * _SQUARE_FT
if dist >= _FIRING_MELEE_SAFE_FT:
return 0
return _FIRING_MELEE_PENALTY return _FIRING_MELEE_PENALTY
def _engaged_in_melee(self, state: CombatantState) -> bool: def _engaged_in_melee(self, state: CombatantState) -> bool:
+30 -4
View File
@@ -338,11 +338,13 @@ def test_firing_into_melee_minus4() -> None:
assert t.hp == 100 assert t.hp == 100
def test_firing_into_melee_avoided_when_target_10ft_away() -> None: def test_firing_into_melee_not_avoided_when_ally_engaged_5ft_away() -> None:
# PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target # PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target
# [...] is at least 10 feet away from the nearest friendly character, you # [...] is at least 10 feet away from the nearest friendly character, you
# can avoid the -4 penalty" — the 10+ ft clause is 0, not -2 (the -2 is # can avoid the -4 penalty" — the exemption is measured from the TARGET to
# reserved for targets two size categories larger, not modeled). # the attacker's nearest friendly character, not from the attacker. The
# archer is 10 ft from the target, but the target is engaged at 5 ft by
# the archer's own ally (the guard) -> the -4 still applies.
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),)) archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99) guard = _combatant("guard", ac=99)
@@ -354,8 +356,32 @@ def test_firing_into_melee_avoided_when_target_10ft_away() -> None:
) )
a, t, _g = eng._states a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 17 assert result.melee_penalty == -4
assert result.total == 13
assert result.hit is False
assert t.hp == 100
def test_firing_into_melee_avoided_when_nearest_ally_20ft_away() -> None:
# The target is engaged (a third-party melee threat stands next to it),
# but the attacker's nearest friendly character is 20 ft from the target
# -> the -4 is avoided even though the archer itself is only 5 ft away.
# The OLD (buggy) rule measured attacker->target/attacker->threat and
# would wrongly apply the -4 here.
archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
ally = _combatant("ally", ac=99)
raider = _combatant("raider") # neither the archer's side nor the target's
eng = _engine(
[archer, tgt, ally, raider],
[(0, 0), (0, 1), (0, 5), (1, 2)],
["players", "monsters", "players", "gnolls"],
[10, 3],
)
a, t, _al, _r = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.melee_penalty == 0 assert result.melee_penalty == 0
assert result.total == 18
assert result.hit is True assert result.hit is True
assert result.damage == 3 assert result.damage == 3
+1 -1
View File
@@ -816,7 +816,7 @@ def test_ranged_attack_ignores_flanking() -> None:
"initiative: aly d20=9+0=9", "initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8", "initiative: tgt d20=8+0=8",
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", "round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 arc: short bow vs tgt d20=12+2-4(melee)=10 AC 15 -> MISS",
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS", "round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",