From 678a4d8b319949b45ee4eb2766fde4dcac61ffda Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 19:33:12 +0200 Subject: [PATCH] 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 --- src/pf1e_simulator/combat.py | 4 +- tests/test_splash_weapons.py | 436 +++++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 tests/test_splash_weapons.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 0f5461f..2cb0cf2 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/tests/test_splash_weapons.py b/tests/test_splash_weapons.py new file mode 100644 index 0000000..e7010ac --- /dev/null +++ b/tests/test_splash_weapons.py @@ -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, + )