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:
|
if TYPE_CHECKING:
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import Pos, TerrainType
|
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.rng import Rng
|
||||||
from pf1e_simulator.spells import SpellSpec
|
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
|
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
|
||||||
# with an attack — only these weapon damage types ride the DR path.
|
# with an attack — only these weapon damage types ride the DR path.
|
||||||
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
|
_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/
|
# PF1e (SRD undead/construct traits, KB rules/advanced-race-guide/
|
||||||
# undead-16-rp.md:15 / construct-20-rp.md:16): "not subject to ... nonlethal
|
# undead-16-rp.md:15 / construct-20-rp.md:16): "not subject to ... nonlethal
|
||||||
# damage" - the same living-creature split death_threshold already draws.
|
# 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
|
# separate nonlethal track (NOT included in ``damage``; ``damage`` only
|
||||||
# grows by the max-hp overflow the track could not hold).
|
# grows by the max-hp overflow the track could not hold).
|
||||||
nonlethal: int = 0
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -3407,11 +3432,25 @@ class CombatEngine:
|
|||||||
lethal_part, tracked = self._apply_nonlethal(target, nonlethal)
|
lethal_part, tracked = self._apply_nonlethal(target, nonlethal)
|
||||||
damage += lethal_part
|
damage += lethal_part
|
||||||
nonlethal = tracked
|
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
|
attacker.aid_attack_bonus = 0
|
||||||
return AttackResult(
|
return AttackResult(
|
||||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
||||||
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
||||||
base_bonus=base_bonus, melee_penalty=fim_penalty, nonlethal=nonlethal,
|
base_bonus=base_bonus, melee_penalty=fim_penalty, nonlethal=nonlethal,
|
||||||
|
splash=splash_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _attack_ac(
|
def _attack_ac(
|
||||||
@@ -3604,6 +3643,103 @@ class CombatEngine:
|
|||||||
# the lethal/nonlethal split of the landed hit (floor included).
|
# the lethal/nonlethal split of the landed hit (floor included).
|
||||||
return crit, False, *_split_landed_damage(weapon, final)
|
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(
|
def _attack_cover_bonus(
|
||||||
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -3662,6 +3798,10 @@ class CombatEngine:
|
|||||||
the data model).
|
the data model).
|
||||||
"""
|
"""
|
||||||
effective_concealment = concealment
|
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"):
|
if self._has_condition_named(attacker, "blinded"):
|
||||||
effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
|
effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
|
||||||
if (
|
if (
|
||||||
@@ -8627,6 +8767,8 @@ class CombatEngine:
|
|||||||
f"d20={result.roll}+{bonus_str}={result.total} "
|
f"d20={result.roll}+{bonus_str}={result.total} "
|
||||||
f"AC {result.ac} -> {outcome}"
|
f"AC {result.ac} -> {outcome}"
|
||||||
)
|
)
|
||||||
|
if result.splash:
|
||||||
|
line += f" + splash {result.splash}"
|
||||||
if result.hit:
|
if result.hit:
|
||||||
line += _hit_damage_segment(result, hp_before, target.hp)
|
line += _hit_damage_segment(result, hp_before, target.hp)
|
||||||
if self._is_paralyzing_touch(attacker, weapon):
|
if self._is_paralyzing_touch(attacker, weapon):
|
||||||
|
|||||||
Reference in New Issue
Block a user