fix(combat): correct hex and AoE DCs, DR bypassed by spells
This commit is contained in:
@@ -1260,7 +1260,7 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return
|
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":
|
if hex_name == "evil_eye":
|
||||||
self._hex_evil_eye(state, target, hex_dc)
|
self._hex_evil_eye(state, target, hex_dc)
|
||||||
@@ -1519,6 +1519,7 @@ class CombatEngine:
|
|||||||
caster=caster, spell_name=spell.name,
|
caster=caster, spell_name=spell.name,
|
||||||
empower="Empower Spell" in metamagic_feats,
|
empower="Empower Spell" in metamagic_feats,
|
||||||
maximize="Maximize Spell" in metamagic_feats,
|
maximize="Maximize Spell" in metamagic_feats,
|
||||||
|
dc=dc,
|
||||||
)
|
)
|
||||||
save_str = _save_label(save_result)
|
save_str = _save_label(save_result)
|
||||||
self._log(
|
self._log(
|
||||||
@@ -1559,6 +1560,7 @@ class CombatEngine:
|
|||||||
spell_name: str,
|
spell_name: str,
|
||||||
empower: bool = False,
|
empower: bool = False,
|
||||||
maximize: bool = False,
|
maximize: bool = False,
|
||||||
|
dc: int = 15,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
saved = save_result is not None and save_result.success
|
saved = save_result is not None and save_result.success
|
||||||
return [
|
return [
|
||||||
@@ -1566,6 +1568,7 @@ class CombatEngine:
|
|||||||
effect, target, caster_live, target_live,
|
effect, target, caster_live, target_live,
|
||||||
caster=caster, spell_name=spell_name,
|
caster=caster, spell_name=spell_name,
|
||||||
empower=empower, maximize=maximize, saved=saved,
|
empower=empower, maximize=maximize, saved=saved,
|
||||||
|
dc=dc,
|
||||||
)
|
)
|
||||||
for effect in effects
|
for effect in effects
|
||||||
]
|
]
|
||||||
@@ -1582,12 +1585,13 @@ class CombatEngine:
|
|||||||
empower: bool = False,
|
empower: bool = False,
|
||||||
maximize: bool = False,
|
maximize: bool = False,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
dc: int = 15,
|
||||||
) -> str:
|
) -> str:
|
||||||
if isinstance(effect, AreaEffect):
|
if isinstance(effect, AreaEffect):
|
||||||
return self._apply_area_effect(
|
return self._apply_area_effect(
|
||||||
effect, target, caster_live, target_live,
|
effect, target, caster_live, target_live,
|
||||||
caster=caster, spell_name=spell_name, saved=saved,
|
caster=caster, spell_name=spell_name, saved=saved,
|
||||||
empower=empower, maximize=maximize,
|
empower=empower, maximize=maximize, dc=dc,
|
||||||
)
|
)
|
||||||
if isinstance(effect, SummonEffect):
|
if isinstance(effect, SummonEffect):
|
||||||
return self._apply_summon_effect(effect, caster, caster_live, spell_name)
|
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
|
dmg = max(0, raw // 2) if saved and effect.half_on_save else raw
|
||||||
if empower:
|
if empower:
|
||||||
dmg = int(dmg * 1.5)
|
dmg = int(dmg * 1.5)
|
||||||
dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target)
|
|
||||||
target.hp -= dmg
|
target.hp -= dmg
|
||||||
caster_live.damage_dealt += dmg
|
caster_live.damage_dealt += dmg
|
||||||
target_live.damage_taken += dmg
|
target_live.damage_taken += dmg
|
||||||
@@ -1691,6 +1694,7 @@ class CombatEngine:
|
|||||||
saved: bool,
|
saved: bool,
|
||||||
empower: bool = False,
|
empower: bool = False,
|
||||||
maximize: bool = False,
|
maximize: bool = False,
|
||||||
|
dc: int = 15,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Apply an area effect to all creatures within radius.
|
"""Apply an area effect to all creatures within radius.
|
||||||
|
|
||||||
@@ -1709,7 +1713,7 @@ class CombatEngine:
|
|||||||
|
|
||||||
for affected_state in affected:
|
for affected_state in affected:
|
||||||
creature_saved = self._area_save(
|
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:
|
for sub_effect in effect.effects:
|
||||||
target_live = self._stats[affected_state.combatant.id]
|
target_live = self._stats[affected_state.combatant.id]
|
||||||
@@ -1751,13 +1755,14 @@ class CombatEngine:
|
|||||||
effect: AreaEffect,
|
effect: AreaEffect,
|
||||||
*,
|
*,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
dc: int = 15,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Save result for one creature in an area effect."""
|
"""Save result for one creature in an area effect."""
|
||||||
if not effect.save_for_half:
|
if not effect.save_for_half:
|
||||||
return False
|
return False
|
||||||
if affected_state is target:
|
if affected_state is target:
|
||||||
return saved
|
return saved
|
||||||
save_result = self.resolve_save(affected_state, "ref", 15)
|
save_result = self.resolve_save(affected_state, "ref", dc)
|
||||||
return save_result.success
|
return save_result.success
|
||||||
|
|
||||||
def _apply_metamagic(
|
def _apply_metamagic(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -395,14 +395,14 @@ class TestSpellDcScalesWithLevel:
|
|||||||
|
|
||||||
|
|
||||||
class TestSpellDamageWithDr:
|
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
|
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()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=200)
|
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={
|
target_with_dr = target.model_copy(update={
|
||||||
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
||||||
})
|
})
|
||||||
@@ -411,8 +411,8 @@ class TestSpellDamageWithDr:
|
|||||||
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
||||||
hp_before = t.hp
|
hp_before = t.hp
|
||||||
engine._cast_spell(c, t, "Fireball")
|
engine._cast_spell(c, t, "Fireball")
|
||||||
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage
|
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage
|
||||||
assert t.hp == hp_before - 13
|
assert t.hp == hp_before - 18
|
||||||
|
|
||||||
|
|
||||||
class TestSummonMonster:
|
class TestSummonMonster:
|
||||||
|
|||||||
Reference in New Issue
Block a user