fix(rules): apply energy resistance per damage component
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Conformance B21: energy resistance/immunity apply per damage component.
|
||||
|
||||
PF1e (CRB "Energy Resistance", "Immunity to Energy Damage"): resistance to
|
||||
an energy type reduces damage of THAT type only; immunity negates that type
|
||||
only. A weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
|
||||
keeps its slashing dice intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.abilities import AbilitySpec
|
||||
from pf1e_simulator.combat import CombatEngine
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec
|
||||
from tests.test_observation_enriched import make_grid, make_state
|
||||
|
||||
|
||||
def _combatant(
|
||||
id_: str,
|
||||
*,
|
||||
level: int = 1,
|
||||
attacks: tuple[AttackSpec, ...] = (),
|
||||
resistances: dict[str, int] | None = None,
|
||||
immunities: tuple[str, ...] = (),
|
||||
features: tuple[str, ...] = (),
|
||||
spell_slots: dict[int, int] | None = None,
|
||||
) -> Combatant:
|
||||
return Combatant(
|
||||
id=id_,
|
||||
name=id_,
|
||||
level=level,
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=10, con_score=10,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||
bab=1,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
hp_max=100,
|
||||
saves=Saves(fort=0, ref=0, will=0),
|
||||
attacks=list(attacks),
|
||||
resistances=resistances or {},
|
||||
immunities=immunities,
|
||||
features=[AbilitySpec(name=f, category="feat") for f in features],
|
||||
spell_slots=spell_slots or {},
|
||||
)
|
||||
|
||||
|
||||
def _attack(
|
||||
aid: str,
|
||||
components: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
*,
|
||||
kind: Literal["melee", "ranged", "touch"] = "melee",
|
||||
) -> AttackSpec:
|
||||
return AttackSpec(
|
||||
id=aid,
|
||||
name=aid,
|
||||
kind=kind,
|
||||
attack_bonus=20,
|
||||
damage=[
|
||||
DamageComponent(formula=parse_dice(formula), types=list(types))
|
||||
for formula, types in components
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _engine(
|
||||
combatants: list[Combatant],
|
||||
positions: list[tuple[int, int]],
|
||||
sides: list[str],
|
||||
rng: list[int],
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
) -> CombatEngine:
|
||||
states = [
|
||||
make_state(c, side, pos)
|
||||
for c, side, pos in zip(combatants, sides, positions, strict=True)
|
||||
]
|
||||
return CombatEngine(
|
||||
ScriptedRng(rng), make_grid(), states, spell_registry=spell_registry
|
||||
)
|
||||
|
||||
|
||||
_MIXED = (
|
||||
("1d6", ("slashing",)),
|
||||
("1d6", ("fire",)),
|
||||
)
|
||||
|
||||
|
||||
def test_fire_resistance_spares_slashing_component() -> None:
|
||||
"""(a) 1d6 slashing + 1d6 fire vs fire resistance 5: only the fire
|
||||
portion is reduced (2 - 5 floors to 0); the slashing dice are intact."""
|
||||
weapon = _attack("fiery-blade", _MIXED)
|
||||
hero = _combatant("hero", attacks=(weapon,))
|
||||
foe = _combatant("foe", resistances={"fire": 5})
|
||||
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||
[15, 6, 2])
|
||||
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||
# d20=15 hits (no threat); slashing 1d6=6, fire 1d6=2 → fire floored at 0.
|
||||
assert result.damage == 6
|
||||
assert eng._states[1].hp == 94
|
||||
|
||||
|
||||
def test_fire_immunity_negates_only_fire_component() -> None:
|
||||
"""(b) fire immunity zeroes the fire portion; the slashing portion lands."""
|
||||
weapon = _attack("fiery-blade", _MIXED)
|
||||
hero = _combatant("hero", attacks=(weapon,))
|
||||
foe = _combatant("foe", immunities=("fire",))
|
||||
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||
[15, 6, 4])
|
||||
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||
assert result.damage == 6
|
||||
assert eng._states[1].hp == 94
|
||||
|
||||
|
||||
def test_physical_only_weapon_ignores_energy_resistance() -> None:
|
||||
"""(c) a physical-only weapon vs fire resistance is untouched."""
|
||||
weapon = _attack("sword", (("1d6", ("slashing",)),))
|
||||
hero = _combatant("hero", attacks=(weapon,))
|
||||
foe = _combatant("foe", resistances={"fire": 5})
|
||||
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||
[15, 5])
|
||||
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||
assert result.damage == 5
|
||||
assert eng._states[1].hp == 95
|
||||
|
||||
|
||||
def test_mixed_energy_component_uses_largest_matching_resistance() -> None:
|
||||
"""(d) one component with several energy types (fire+cold) vs resist
|
||||
fire 5 / cold 10 → the largest matching resistance (10) applies."""
|
||||
weapon = _attack("dual-energy", (("2d6", ("fire", "cold")),))
|
||||
hero = _combatant("hero", attacks=(weapon,))
|
||||
foe = _combatant("foe", resistances={"fire": 5, "cold": 10})
|
||||
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||
[15, 6, 6])
|
||||
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||
# 2d6 = 12, reduced by cold 10 (largest match), not by fire 5.
|
||||
assert result.damage == 2
|
||||
assert eng._states[1].hp == 98
|
||||
|
||||
|
||||
_FIREBALL = SpellSpec(
|
||||
name="Fireball",
|
||||
level=3,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="ref",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(
|
||||
radius_ft=20,
|
||||
effects=(
|
||||
DamageEffect(
|
||||
formula=parse_dice("8d6"),
|
||||
types=("fire",),
|
||||
half_on_save=True,
|
||||
),
|
||||
),
|
||||
save_for_half=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_spell_fire_damage_resistance_regression() -> None:
|
||||
"""(e) a fire spell vs fire resistance still reduces like before the
|
||||
refactor: 8d6 = 24 (save failed on natural 1) - 5 = 19."""
|
||||
caster = _combatant("caster", spell_slots={3: 9})
|
||||
foe = _combatant("foe", resistances={"fire": 5})
|
||||
eng = _engine(
|
||||
[caster, foe], [(0, 0), (0, 5)], ["players", "monsters"],
|
||||
[1, 3, 3, 3, 3, 3, 3, 3, 3],
|
||||
spell_registry={"Fireball": _FIREBALL},
|
||||
)
|
||||
eng._cast_spell(eng._states[0], eng._states[1], "Fireball")
|
||||
assert eng._states[1].hp == 81
|
||||
|
||||
|
||||
def test_sneak_precision_not_reduced_by_energy_resistance() -> None:
|
||||
"""(f) sneak attack d6s are untyped precision — energy resistance may
|
||||
only eat the weapon's own energy dice, never the precision dice."""
|
||||
weapon = _attack("flame-dagger", (("1d6", ("fire",)),))
|
||||
rogue = _combatant(
|
||||
"rogue", attacks=(weapon,), features=("Sneak Attack",)
|
||||
)
|
||||
ally = _combatant(
|
||||
"ally", attacks=(_attack("club", (("1d6", ("bludgeoning",)),)),)
|
||||
)
|
||||
foe = _combatant("foe", resistances={"fire": 10})
|
||||
# Rogue (0,0) and ally (0,2) flank the foe (0,1): the sneak attack lands.
|
||||
eng = _engine([rogue, ally, foe], [(0, 0), (0, 2), (0, 1)],
|
||||
["players", "players", "monsters"], [15, 6, 4])
|
||||
result = eng.resolve_attack(eng._states[0], eng._states[2], weapon)
|
||||
# d20=15 hits, sneak 1d6=6, fire 1d6=4 → fire floored at 0 by resist 10;
|
||||
# the 6 precision dice survive intact (the old lumped path dealt 0).
|
||||
assert result.damage == 6
|
||||
assert eng._states[2].hp == 94
|
||||
Reference in New Issue
Block a user