"""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 -> the -2 attack hex lands for its full 3 rounds (APG Evil Eye: a save only reduces the duration to 1 round). DC 12 (wrong, uses CHA): 15 >= 12 -> "saved" -> 1 round. """ 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("-2 attack (3r)" 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