feat(rules): splash weapon resolution - direct hit splashes 1 energy to all within 5 ft (target excluded), miss scatters d8 (1=short toward thrower, 2-8 clockwise) one range increment and splashes the landing + adjacent squares; energy resistance applies, DR and crit never; no precision damage (SRD Throw Splash Weapon)
This commit is contained in:
@@ -163,7 +163,12 @@ from pf1e_simulator.spells import (
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import Pos, TerrainType
|
||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||
from pf1e_simulator.models import (
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageReduction,
|
||||
SplashSpec,
|
||||
)
|
||||
from pf1e_simulator.rng import Rng
|
||||
from pf1e_simulator.spells import SpellSpec
|
||||
|
||||
@@ -224,6 +229,21 @@ _DEAFENED_VERBAL_FAILURE_PCT = 20
|
||||
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
|
||||
# with an attack — only these weapon damage types ride the DR path.
|
||||
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
|
||||
# PF1e (SRD "Throw Splash Weapon", KB rules/prpg-core-rulebook/
|
||||
# throw-splash-weapon.md:13): "If you miss the target [...] roll 1d8. This
|
||||
# determines the misdirection of the throw, with 1 falling short (off-target
|
||||
# in a straight line toward the thrower), and 2 through 8 rotating around the
|
||||
# target creature or grid intersection in a clockwise direction. Then, count a
|
||||
# number of squares in the indicated direction equal to the range increment of
|
||||
# the throw [...] it deals splash damage to all creatures in that square and
|
||||
# in all adjacent squares."
|
||||
_SPLASH_D8_SIDES = 8
|
||||
# The eight unit vectors in clockwise compass order starting at north (row 0
|
||||
# is the TOP row of the displayed ASCII map, so displayed-clockwise is
|
||||
# N, NE, E, SE, S, SW, W, NW).
|
||||
_SPLASH_COMPASS_CW: tuple[tuple[int, int], ...] = (
|
||||
(-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1),
|
||||
)
|
||||
# PF1e (SRD undead/construct traits, KB rules/advanced-race-guide/
|
||||
# undead-16-rp.md:15 / construct-20-rp.md:16): "not subject to ... nonlethal
|
||||
# damage" - the same living-creature split death_threshold already draws.
|
||||
@@ -1161,6 +1181,11 @@ class AttackResult:
|
||||
# separate nonlethal track (NOT included in ``damage``; ``damage`` only
|
||||
# grows by the max-hp overflow the track could not hold).
|
||||
nonlethal: int = 0
|
||||
# SRD "Throw Splash Weapon": total splash damage this attack dealt to the
|
||||
# creatures around the point of impact (0 for a non-splash weapon). The
|
||||
# splash is flat energy damage - never multiplied on a critical, never
|
||||
# cut by DR, and NOT included in ``damage`` (which is target-only).
|
||||
splash: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -3407,11 +3432,25 @@ class CombatEngine:
|
||||
lethal_part, tracked = self._apply_nonlethal(target, nonlethal)
|
||||
damage += lethal_part
|
||||
nonlethal = tracked
|
||||
splash_total = 0
|
||||
if weapon.splash is not None:
|
||||
# SRD "Throw Splash Weapon": a direct hit splashes every creature
|
||||
# within 5 feet of the target's square (the target itself took
|
||||
# the direct-hit damage, not an extra splash); any miss - attack
|
||||
# roll or concealment - scatters the flask and deals ONLY the
|
||||
# splash at where it lands.
|
||||
if hit and not concealment_miss:
|
||||
splash_total = self._splash_direct_hit(
|
||||
attacker, target, weapon.splash
|
||||
)
|
||||
else:
|
||||
splash_total = self._splash_scatter(attacker, target, weapon)
|
||||
attacker.aid_attack_bonus = 0
|
||||
return AttackResult(
|
||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
||||
base_bonus=base_bonus, melee_penalty=fim_penalty, nonlethal=nonlethal,
|
||||
splash=splash_total,
|
||||
)
|
||||
|
||||
def _attack_ac(
|
||||
@@ -3604,6 +3643,103 @@ class CombatEngine:
|
||||
# the lethal/nonlethal split of the landed hit (floor included).
|
||||
return crit, False, *_split_landed_damage(weapon, final)
|
||||
|
||||
def _splash_direct_hit(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
target: CombatantState,
|
||||
splash: SplashSpec,
|
||||
) -> int:
|
||||
"""Splash a direct hit: every creature within 5 feet of the point of
|
||||
impact (the target's square) takes the splash - the target itself
|
||||
took the direct-hit damage, not an extra splash (SRD "Throw Splash
|
||||
Weapon": "A hit deals direct hit damage to the target and splash
|
||||
damage to all creatures within 5 feet of the target").
|
||||
"""
|
||||
return self._apply_splash(attacker, splash, target.pos, exclude=target)
|
||||
|
||||
def _splash_scatter(
|
||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||
) -> int:
|
||||
"""Splash a miss (SRD "Throw Splash Weapon"): roll 1d8 for the
|
||||
misdirection - 1 falls short in a straight line toward the thrower,
|
||||
2-8 rotate clockwise around the target - then count squares equal to
|
||||
the range increment of the throw. The flask deals ONLY its splash
|
||||
(no direct hit) to every creature in the landing square and all
|
||||
adjacent squares - the original target included, it is a creature
|
||||
like any other in the blast. The d8 is rolled solely for splash
|
||||
weapons, so every other attack's RNG stream is untouched.
|
||||
"""
|
||||
splash = weapon.splash
|
||||
if splash is None: # unreachable: scatter only runs for splash weapons
|
||||
return 0
|
||||
roll = self._rng.roll_die(_SPLASH_D8_SIDES)
|
||||
vec = _SPLASH_COMPASS_CW[
|
||||
(self._splash_toward_thrower(attacker, target) + roll - 1)
|
||||
% _SPLASH_D8_SIDES
|
||||
]
|
||||
increment = weapon.range_increment_ft
|
||||
steps = max(1, (increment if increment is not None else _SQUARE_FT) // _SQUARE_FT)
|
||||
landing = (target.pos[0] + vec[0] * steps, target.pos[1] + vec[1] * steps)
|
||||
self._log(
|
||||
f"round {self._current_round} {attacker.combatant.id}: "
|
||||
f"splash scatter d8={roll} -> cell {landing[0]},{landing[1]}"
|
||||
)
|
||||
return self._apply_splash(attacker, splash, landing, exclude=None)
|
||||
|
||||
@staticmethod
|
||||
def _splash_toward_thrower(
|
||||
attacker: CombatantState, target: CombatantState
|
||||
) -> int:
|
||||
"""Index into ``_SPLASH_COMPASS_CW`` of the unit direction from the
|
||||
target's square toward the thrower (the d8=1 "falls short" line). A
|
||||
same-square thrower (0,0) matches no compass vector and falls back to
|
||||
index 0 (north) - deterministic, no extra die.
|
||||
"""
|
||||
dr = (attacker.pos[0] > target.pos[0]) - (attacker.pos[0] < target.pos[0])
|
||||
dc = (attacker.pos[1] > target.pos[1]) - (attacker.pos[1] < target.pos[1])
|
||||
try:
|
||||
return _SPLASH_COMPASS_CW.index((dr, dc))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _apply_splash(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
splash: SplashSpec,
|
||||
impact: Pos,
|
||||
*,
|
||||
exclude: CombatantState | None,
|
||||
) -> int:
|
||||
"""Deal the splash's flat energy damage to every creature in or
|
||||
adjacent to the impact square (SRD "Throw Splash Weapon": "no attack
|
||||
roll, no save" - the splash is automatic). It is energy damage: the
|
||||
component-based energy resistance/immunity path applies and DR does
|
||||
not; the flat amount never rides the crit multiplier (it is outside
|
||||
the weapon-damage path entirely). Dying/unconscious creatures are
|
||||
caught like any area effect; corpses are not. Returns the total
|
||||
splash damage dealt (booked to the attacker's damage_dealt and each
|
||||
victim's damage_taken).
|
||||
"""
|
||||
total = 0
|
||||
attacker_live = self._stats[attacker.combatant.id]
|
||||
damage_types = frozenset({splash.type})
|
||||
for state in self._creatures_in_range(impact, 1, active_only=False):
|
||||
if state is exclude:
|
||||
continue
|
||||
reduction = self._energy_reduction(splash.damage, damage_types, state)
|
||||
damage = max(0, splash.damage - reduction)
|
||||
old_hp = state.hp
|
||||
self._lose_hp(state, damage)
|
||||
self._stats[state.combatant.id].damage_taken += damage
|
||||
attacker_live.damage_dealt += damage
|
||||
total += damage
|
||||
self._log(
|
||||
f"round {self._current_round} {attacker.combatant.id}: "
|
||||
f"splash {damage} {splash.type} -> {state.combatant.id} "
|
||||
f"(hp {old_hp}->{state.hp})"
|
||||
)
|
||||
return total
|
||||
|
||||
def _attack_cover_bonus(
|
||||
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
||||
) -> int:
|
||||
@@ -3662,6 +3798,10 @@ class CombatEngine:
|
||||
the data model).
|
||||
"""
|
||||
effective_concealment = concealment
|
||||
if weapon.splash is not None:
|
||||
# SRD "Throw Splash Weapon": "Splash weapons cannot deal
|
||||
# precision-based damage (such as sneak attack)."
|
||||
return 0
|
||||
if self._has_condition_named(attacker, "blinded"):
|
||||
effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
|
||||
if (
|
||||
@@ -8627,6 +8767,8 @@ class CombatEngine:
|
||||
f"d20={result.roll}+{bonus_str}={result.total} "
|
||||
f"AC {result.ac} -> {outcome}"
|
||||
)
|
||||
if result.splash:
|
||||
line += f" + splash {result.splash}"
|
||||
if result.hit:
|
||||
line += _hit_damage_segment(result, hp_before, target.hp)
|
||||
if self._is_paralyzing_touch(attacker, weapon):
|
||||
|
||||
Reference in New Issue
Block a user