"""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, )