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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user