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:
2026-09-20 19:18:46 +02:00
parent 9cfcd67f70
commit 2e60bbb419
+143 -1
View File
@@ -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):