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:
@@ -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,11 +3089,16 @@ 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 = [
|
||||||
return 0
|
s
|
||||||
for threat in threats:
|
for s in self._states
|
||||||
dist = self.creature_distance(attacker, threat) * _SQUARE_FT
|
if s is not attacker and s.active and s.side == attacker.side
|
||||||
if dist >= _FIRING_MELEE_SAFE_FT:
|
]
|
||||||
|
if all(
|
||||||
|
self.creature_distance(target, ally) * _SQUARE_FT
|
||||||
|
>= _FIRING_MELEE_SAFE_FT
|
||||||
|
for ally in allies
|
||||||
|
):
|
||||||
return 0
|
return 0
|
||||||
return _FIRING_MELEE_PENALTY
|
return _FIRING_MELEE_PENALTY
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user