437 lines
17 KiB
Python
437 lines
17 KiB
Python
"""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,
|
|
)
|