Files
pf1e-simulator/tests/test_hex_aoe_dr.py
T

163 lines
5.3 KiB
Python

"""Tests for hex DC, AoE save DC, and spell damage DR bypass (Task 5)."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import load_spell_registry
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def _make_caster(cid: str = "caster", *, level: int = 5, int_score: int = 18) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=int_score, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spell_slots={1: 4, 2: 3, 3: 2},
resources={"hexes": 3, "channel_energy": 6},
)
def _make_target(cid: str = "target", *, hp_max: int = 30) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp_max,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_engine(
caster: Combatant,
target: Combatant,
queue: list[int],
*,
caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (3, 0),
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(
combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max,
resources_left=dict(caster.resources),
)
t = CombatantState(
combatant=target, side="monsters", pos=target_pos, hp=target.hp_max,
resources_left=dict(target.resources),
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
class TestHexDcUsesInt:
"""Given: a witch with level 4, INT 21 (mod +5)
When: evil_eye hex is cast (Will save)
Then: DC = 10 + 4//2 + (21-10)//2 = 17.
Behavioral test: target has Will +2. d20=13 -> total 15.
DC 17: 15 < 17 -> fail -> shaken applied.
DC 12 (wrong, uses CHA): 15 >= 12 -> success -> no effect.
"""
def test_hex_dc_uses_int_not_cha(self) -> None:
caster = _make_caster(level=4, int_score=21)
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [13])
engine._execute_hex(c, t, "evil_eye")
assert any("shaken" in line for line in engine._transcript)
class TestAoEDcEqualsCasterSpellDc:
"""Given: a caster with INT 18 (mod +4) casting Fireball (level 3)
When: the AoE targets a secondary creature (caster self in area)
Then: the area save DC equals the caster's spell DC (17), not hardcoded 15.
Behavioral test: secondary target (caster) has ref +4.
d20=12 -> total 12+4=16. DC 17 -> 16<17 -> fail (full damage).
If DC 15 (old bug): 16>=15 -> success (half damage).
"""
def test_area_save_uses_caster_dc(self) -> None:
caster = _make_caster(level=5, int_score=18)
target = _make_target(hp_max=200)
queue = [1, 12, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target, queue)
hp_before_caster = c.hp
hp_before_target = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before_target - 18
assert c.hp == hp_before_caster - 18
class TestSpellDamageBypassesDr:
"""Given: a target with DR 5/- (bypassed by nothing)
When: a spell deals fire damage
Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR)."""
def test_dr_does_not_reduce_spell_damage(self) -> None:
caster = _make_caster(level=5, int_score=18)
target = _make_target(hp_max=200)
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()),
})
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - 18