From 0058e715504c5f92550d5b9d7071083b39293eea Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:53 +0200 Subject: [PATCH] fix(combat): correct hex and AoE DCs, DR bypassed by spells --- src/pf1e_simulator/combat.py | 15 ++-- tests/test_hex_aoe_dr.py | 162 +++++++++++++++++++++++++++++++++++ tests/test_spells.py | 12 +-- 3 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 tests/test_hex_aoe_dr.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 45c6a55..013e8c8 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1260,7 +1260,7 @@ class CombatEngine: ) return - hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2 + hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.int_score - 10) // 2 if hex_name == "evil_eye": self._hex_evil_eye(state, target, hex_dc) @@ -1519,6 +1519,7 @@ class CombatEngine: caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats, + dc=dc, ) save_str = _save_label(save_result) self._log( @@ -1559,6 +1560,7 @@ class CombatEngine: spell_name: str, empower: bool = False, maximize: bool = False, + dc: int = 15, ) -> list[str]: saved = save_result is not None and save_result.success return [ @@ -1566,6 +1568,7 @@ class CombatEngine: effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, + dc=dc, ) for effect in effects ] @@ -1582,12 +1585,13 @@ class CombatEngine: empower: bool = False, maximize: bool = False, saved: bool, + dc: int = 15, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, saved=saved, - empower=empower, maximize=maximize, + empower=empower, maximize=maximize, dc=dc, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) @@ -1619,7 +1623,6 @@ class CombatEngine: dmg = max(0, raw // 2) if saved and effect.half_on_save else raw if empower: dmg = int(dmg * 1.5) - dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) target.hp -= dmg caster_live.damage_dealt += dmg target_live.damage_taken += dmg @@ -1691,6 +1694,7 @@ class CombatEngine: saved: bool, empower: bool = False, maximize: bool = False, + dc: int = 15, ) -> str: """Apply an area effect to all creatures within radius. @@ -1709,7 +1713,7 @@ class CombatEngine: for affected_state in affected: creature_saved = self._area_save( - affected_state, target, effect, saved=saved + affected_state, target, effect, saved=saved, dc=dc ) for sub_effect in effect.effects: target_live = self._stats[affected_state.combatant.id] @@ -1751,13 +1755,14 @@ class CombatEngine: effect: AreaEffect, *, saved: bool, + dc: int = 15, ) -> bool: """Save result for one creature in an area effect.""" if not effect.save_for_half: return False if affected_state is target: return saved - save_result = self.resolve_save(affected_state, "ref", 15) + save_result = self.resolve_save(affected_state, "ref", dc) return save_result.success def _apply_metamagic( diff --git a/tests/test_hex_aoe_dr.py b/tests/test_hex_aoe_dr.py new file mode 100644 index 0000000..a7cb5d0 --- /dev/null +++ b/tests/test_hex_aoe_dr.py @@ -0,0 +1,162 @@ +"""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 diff --git a/tests/test_spells.py b/tests/test_spells.py index 5341828..8e3c2b9 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -395,14 +395,14 @@ class TestSpellDcScalesWithLevel: class TestSpellDamageWithDr: - """Given: a target with DR vs the spell's damage type + """Given: a target with DR 5/- (bypassed by nothing) When: a spell deals damage - Then: DR is applied after save halving.""" + Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR).""" - def test_dr_reduces_spell_damage(self) -> None: + def test_dr_does_not_reduce_spell_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) - # DR 5/- (bypassed by nothing) applies to all damage including fire + # DR 5/- does NOT apply to spell damage target_with_dr = target.model_copy(update={ "dr": DamageReduction(amount=5, bypass=frozenset()), }) @@ -411,8 +411,8 @@ class TestSpellDamageWithDr: engine, c, t = _make_engine(caster, target_with_dr, queue) hp_before = t.hp engine._cast_spell(c, t, "Fireball") - # Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage - assert t.hp == hp_before - 13 + # Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage + assert t.hp == hp_before - 18 class TestSummonMonster: