feat(rules): splash weapon test suite - direct hit full damage + 1 splash to adjacent only, miss scatters d8 clockwise one range increment dealing splash alone (thrower can be caught), energy resistance/immunity apply, DR does not, splash never crit-multiplied, no precision damage, non-splash RNG stream bit-identical, schema rejects splash on melee, observation exposes splash; transcript logs splash after the damage segment

This commit is contained in:
2026-09-20 19:33:12 +02:00
parent a1b1c29ca3
commit 678a4d8b31
2 changed files with 438 additions and 2 deletions
+2 -2
View File
@@ -8772,8 +8772,6 @@ 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):
@@ -8787,6 +8785,8 @@ class CombatEngine:
)
)
line += f" paralyzed ({duration}r)"
if result.splash:
line += f" + splash {result.splash}"
self._log(line)
if target.dead:
self._log(
+436
View File
@@ -0,0 +1,436 @@
"""SRD "Throw Splash Weapon" (KB rules/prpg-core-rulebook/throw-splash-weapon.md)
and SRD "Alchemist's fire" / "Acid": a splash weapon is a thrown ranged touch
attack; a direct hit deals its damage to the target and FLAT splash damage to
every creature within 5 feet (no attack roll, no save); a miss scatters 1d8
(1 = short toward the thrower, 2-8 clockwise) one range increment and deals
ONLY the splash at the landing square and its adjacent squares. The splash is
energy damage: resistance/immunity apply, DR does not, and the flat amount is
never multiplied on a critical nor replaced by precision damage.
Given/When/Then docstrings follow the repo BDD test convention.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import pytest
from pydantic import ValidationError
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import RngExhaustedError, ScriptedRng
PCS_DIR = Path(__file__).resolve().parent.parent / "data" / "pcs"
def splash_attack(
*,
attack_bonus: int = 20,
increment: int = 10,
splash_damage: int = 1,
splash_type: str = "fire",
) -> AttackSpec:
return AttackSpec(
id="af",
name="Alchemist's Fire",
kind="touch",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["fire"])],
range_increment_ft=increment,
splash={"damage": splash_damage, "type": splash_type},
)
def plain_attack(*, kind: Literal["ranged", "touch"] = "ranged") -> AttackSpec:
return AttackSpec(
id="bow",
name="short bow",
kind=kind,
attack_bonus=20,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=60,
)
def make_combatant(
cid: str,
attack: AttackSpec,
*,
hp: int = 20,
ac: int = 10,
resistances: dict[str, int] | None = None,
immunities: tuple[str, ...] = (),
dr: DamageReduction | None = None,
sneak: bool = False,
) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=1,
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=10,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
dr=dr,
resistances=resistances or {},
immunities=immunities,
features=[AbilitySpec(name="Sneak Attack", category="class")] if sneak else [],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
return Grid.from_spec(spec)
def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng(queue), make_grid(), states, round_cap=10)
def _add_condition(state: CombatantState, name: str, rounds: int = 5) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME[name],
rounds_left=rounds,
source="test",
)
)
def thrower_and_witnesses(
splash: AttackSpec | None = None,
) -> tuple[CombatantState, AttackSpec, CombatantState, CombatantState, CombatantState]:
"""Thrower at (0,0), target at (0,4), one creature adjacent to the
target at (0,3), one far away at (0,8)."""
weapon = splash if splash is not None else splash_attack()
th = make_state(make_combatant("thrower", weapon), "throwers", (0, 0))
tg = make_state(make_combatant("tg", plain_attack()), "foes", (0, 4))
adj = make_state(make_combatant("adj", plain_attack()), "foes", (0, 3))
far = make_state(make_combatant("far", plain_attack()), "foes", (0, 8))
return th, weapon, tg, adj, far
def test_direct_hit_deals_full_damage_to_target_and_splash_to_adjacent() -> None:
"""Given: a splash throw and a creature adjacent to the target
When: the attack roll hits (d20=10, 1d6=5)
Then: the target takes the full 1d6, the adjacent creature takes exactly
1 splash, the far creature takes nothing, and the target is NOT splashed
on top of its direct hit."""
th, weapon, tg, adj, far = thrower_and_witnesses()
engine = make_engine([10, 5], [th, tg, adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is True
assert result.damage == 5
assert result.splash == 1
assert tg.hp == 15
assert adj.hp == 19
assert far.hp == 20
def test_miss_scatters_short_and_deals_only_splash() -> None:
"""Given: a natural 1 (auto-miss) with the thrower due west of the target
When: the scatter d8 is 1 ("falling short ... toward the thrower")
Then: the flask lands 2 cells west of the target (one 10-ft range
increment), dealing ONLY splash - the target takes no direct damage but
the creature adjacent to the landing square takes 1."""
th, weapon, tg, adj, _far = thrower_and_witnesses()
engine = make_engine([1, 1], [th, tg, adj, _far])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is False
assert result.damage == 0
assert result.splash == 1
assert tg.hp == 20
assert adj.hp == 19
scatter_lines = [line for line in engine._transcript if "scatter" in line]
assert len(scatter_lines) == 1
assert "d8=1" in scatter_lines[0]
assert "cell 0,2" in scatter_lines[0]
def test_scatter_rotates_clockwise_and_lands_one_range_increment_away() -> None:
"""Given: a miss with the thrower due west of the target at (0,4)
When: the scatter d8 is 7 (base direction west rotated 6 steps
clockwise = due south)
Then: the flask lands at (2,4) - two cells (10 ft) south of the target -
and splashes the creature adjacent to the landing square only."""
th, weapon, tg, _adj, _far = thrower_and_witnesses()
victim = make_state(make_combatant("vic", plain_attack()), "foes", (1, 4))
engine = make_engine([1, 7], [th, tg, _adj, _far, victim])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is False
assert result.splash == 1
assert victim.hp == 19
assert tg.hp == 20
assert any("cell 2,4" in line for line in engine._transcript)
def test_scatter_can_splash_the_thrower_themselves() -> None:
"""Given: a thrower 6 cells west of the target with a 30-ft (6-cell)
range increment weapon
When: a miss scatters short (d8=1) exactly onto the thrower's square
Then: the thrower is a creature like any other in the blast and takes 1
splash - the original target is NOT excluded from scatter splash."""
weapon = splash_attack(increment=30)
th = make_state(make_combatant("thrower", weapon), "throwers", (0, 0))
tg = make_state(make_combatant("tg", plain_attack()), "foes", (0, 6))
engine = make_engine([1, 1], [th, tg])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is False
assert result.splash == 1
assert th.hp == 19
assert tg.hp == 20
def test_energy_resistance_reduces_splash() -> None:
"""Given: an adjacent creature with fire resistance 5
When: a direct hit splashes it for 1 fire
Then: the resistance eats the splash entirely (0 damage dealt)."""
th, weapon, tg, adj, far = thrower_and_witnesses()
adj.combatant = make_combatant(
"adj", plain_attack(), resistances={"fire": 5}
)
engine = make_engine([10, 5], [th, tg, adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is True
assert result.splash == 0
assert adj.hp == 20
def test_energy_immunity_cancels_splash() -> None:
"""Given: an adjacent creature immune to fire
When: a direct hit splashes it
Then: the splash is cancelled (SRD: energy immunity, no save)."""
th, weapon, tg, adj, far = thrower_and_witnesses()
adj.combatant = make_combatant("adj", plain_attack(), immunities=("fire",))
engine = make_engine([10, 5], [th, tg, adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.splash == 0
assert adj.hp == 20
def test_dr_does_not_reduce_splash() -> None:
"""Given: an adjacent creature with DR 10/slashing
When: a direct hit splashes it for 1 fire
Then: DR does not apply (splash is energy damage, SRD CRB "Damage
Reduction") - it still takes the full 1."""
th, weapon, tg, adj, far = thrower_and_witnesses()
adj.combatant = make_combatant(
"adj", plain_attack(), dr=DamageReduction(amount=10, bypass=frozenset({"slashing"}))
)
engine = make_engine([10, 5], [th, tg, adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.splash == 1
assert adj.hp == 19
def test_splash_is_never_multiplied_on_a_critical() -> None:
"""Given: a splash throw that crits (d20=20 auto-hits, confirm 20)
When: the direct hit lands
Then: the splash to the adjacent creature is still exactly 1 - the flat
splash never rides the crit multiplier."""
th, weapon, tg, adj, far = thrower_and_witnesses()
engine = make_engine([20, 20, 4], [th, tg, adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is True
assert result.crit is True
assert result.splash == 1
assert adj.hp == 19
def test_splash_weapon_cannot_deal_precision_damage() -> None:
"""Given: a rogue throwing alchemist's fire at a flat-footed target
within 30 ft (sneak attack would otherwise apply to a ranged touch)
When: the attack resolves with a queue of exactly d20 + 1d6
Then: no sneak d6 is rolled (SRD: "Splash weapons cannot deal
precision-based damage") - the damage die lands and nothing is exhausted."""
weapon = splash_attack()
th = make_state(make_combatant("thrower", weapon, sneak=True), "throwers", (0, 0))
tg = make_state(make_combatant("tg", plain_attack(), ac=1), "foes", (0, 4))
_add_condition(tg, "flat_footed")
engine = make_engine([10, 5], [th, tg])
result = engine.resolve_attack(th, tg, weapon)
assert result.hit is True
assert result.damage == 5
assert result.splash == 0
def test_direct_hit_splash_hits_creatures_sharing_the_impact_square() -> None:
"""Given: a Tiny-or-smaller creature co-occupying the target's square
When: a direct hit lands
Then: the co-occupant is "within 5 feet" of the point of impact and takes
the splash (the impact square itself is in the splash area, not just its
neighbours)."""
th, weapon, tg, _adj, far = thrower_and_witnesses()
buddy = make_state(make_combatant("buddy", plain_attack()), "foes", (0, 4))
engine = make_engine([10, 5], [th, tg, buddy, _adj, far])
result = engine.resolve_attack(th, tg, weapon)
assert result.splash == 2
assert buddy.hp == 19
assert _adj.hp == 19
def test_non_splash_miss_consumes_no_extra_die() -> None:
"""Given: a plain ranged attack that misses on a natural 1
When: the queue holds exactly one die (the d20)
Then: resolution succeeds without touching the RNG again - non-splash
weapons never roll the scatter d8 (RNG stream unchanged)."""
bow = plain_attack()
th = make_state(make_combatant("thrower", bow), "throwers", (0, 0))
tg = make_state(make_combatant("tg", bow), "foes", (0, 4))
engine = make_engine([1], [th, tg])
result = engine.resolve_attack(th, tg, bow)
assert result.hit is False
assert result.splash == 0
def test_splash_miss_consumes_exactly_one_extra_die() -> None:
"""Given: a splash weapon miss
When: the queue holds only the d20
Then: the scatter d8 raises exhaustion (exactly ONE extra die is
consumed); with the d8 scripted the same attack resolves cleanly."""
weapon = splash_attack()
th, tg = (
make_state(make_combatant("thrower", weapon), "throwers", (0, 0)),
make_state(make_combatant("tg", plain_attack()), "foes", (0, 4)),
)
starved = make_engine([1], [th, tg])
with pytest.raises(RngExhaustedError):
starved.resolve_attack(th, tg, weapon)
armed = make_engine([1, 3], [th, tg])
result = armed.resolve_attack(th, tg, weapon)
assert result.hit is False
def test_melee_attack_rng_stream_unchanged() -> None:
"""Given: a plain melee attack that hits
When: the queue holds exactly d20 + damage die
Then: resolution succeeds - splash added no hidden rolls to non-splash
paths."""
sword = AttackSpec(
id="sw",
name="short sword",
kind="melee",
attack_bonus=20,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
th = make_state(make_combatant("a", sword), "players", (0, 0))
tg = make_state(make_combatant("b", sword), "monsters", (0, 1))
engine = make_engine([10, 4], [th, tg])
result = engine.resolve_attack(th, tg, sword)
assert result.hit is True
assert result.damage == 4
assert result.splash == 0
def test_transcript_reports_splash_on_hit_and_miss() -> None:
"""Given: a splash hit and a splash scatter miss
When: swings resolve through _resolve_swing (full engine action path)
Then: the attack line carries "+ splash N" and the miss line carries the
scatter log - visible in the transcript."""
weapon = splash_attack()
th = make_state(make_combatant("thrower", weapon), "throwers", (0, 0))
tg = make_state(make_combatant("tg", plain_attack(), ac=1), "foes", (0, 4))
adj = make_state(make_combatant("adj", plain_attack()), "foes", (0, 3))
engine = make_engine([10, 5, 1, 1], [th, tg, adj])
engine._resolve_swing(th, tg, weapon, label=weapon.name)
engine._resolve_swing(th, tg, weapon, label=weapon.name)
lines = engine._transcript
hit_lines = [line for line in lines if "-> HIT" in line]
miss_lines = [line for line in lines if "-> MISS" in line]
assert len(hit_lines) == 1
assert len(miss_lines) == 1
assert "5 damage" in hit_lines[0]
assert "+ splash 1" in hit_lines[0]
assert miss_lines[0].endswith("MISS + splash 1")
assert any("splash scatter d8=1" in line for line in lines)
def test_observation_exposes_splash_on_the_attack_reference() -> None:
"""Given: a combatant wielding a splash weapon
When: an Observation snapshot is built
Then: the attacks reference carries splash {damage, type} for the splash
weapon and None for plain weapons."""
weapon = splash_attack()
th = make_state(make_combatant("thrower", weapon), "throwers", (0, 0))
tg = make_state(make_combatant("tg", plain_attack()), "foes", (0, 4))
engine = make_engine([], [th, tg])
obs = engine._build_observation(th).to_dict()
thrower_entry = next(c for c in obs["combatants"] if c["id"] == "thrower")
assert thrower_entry["attacks"][0]["splash"] == {"damage": 1, "type": "fire"}
obs_tg = engine._build_observation(tg).to_dict()
tg_entry = next(c for c in obs_tg["combatants"] if c["id"] == "tg")
assert tg_entry["attacks"][0]["splash"] is None
def test_data_alchemists_fire_carries_splash_marker() -> None:
"""Given: the canonical PC JSONs
When: oni and tammara are loaded
Then: their Alchemist's Fire is a 10-ft-increment ranged touch attack
with splash 1 fire (SRD "Alchemist's fire")."""
for name in ("oni", "tammara"):
combatant = load_combatant(PCS_DIR / f"{name}.json")
fire = [a for a in combatant.attacks if a.name == "Alchemist's Fire"]
assert len(fire) == 1, name
assert fire[0].kind == "touch"
assert fire[0].range_increment_ft == 10
assert fire[0].splash is not None
assert fire[0].splash.damage == 1
assert fire[0].splash.type == "fire"
def test_splash_rejected_on_melee_and_increment_less_attacks() -> None:
"""Given: splash payloads authored on illegal weapons
When: the schema validates them
Then: both are rejected - a splash weapon is a THROWN ranged attack."""
splash = {"damage": 1, "type": "fire"}
with pytest.raises(ValidationError):
AttackSpec(
id="m",
name="splash dagger",
kind="melee",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
splash=splash,
)
with pytest.raises(ValidationError):
AttackSpec(
id="t",
name="touch splash",
kind="touch",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["fire"])],
splash=splash,
)