diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 7ae4d58..aed3e38 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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):