Files
pf1e-simulator/tests/test_conformance_energy.py

267 lines
9.7 KiB
Python

"""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,
DamageReduction,
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_crit_does_not_multiply_flaming_dice() -> None:
"""CRB Critical Hits: "additional damage dice from weapon special
abilities (such as flaming) are not multiplied when you score a critical
hit" — the slashing die rides the x2, the fire die stays at 1x."""
weapon = _attack("fiery-blade", _MIXED)
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe")
# d20=20 threatens, confirm 20 auto-crits, slashing 1d6=6, fire 1d6=4.
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[20, 20, 6, 4])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
assert result.crit is True
# 6x2 + 4 = 16 — NOT (6+4)x2 = 20 (the old multiply-everything convention).
assert result.damage == 16
assert eng._states[1].hp == 84
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
def test_dr_spares_the_energy_component_of_a_mixed_attack() -> None:
"""(g) A3: DR reduces only the physical share — energy dice pass through.
PF1e (CRB "Damage Reduction"): "Damage reduction does not negate ...
energy damage dealt along with an attack." 1d8 piercing = 3 + 1d6 fire = 6
against DR 5: the old lumped path dealt max(0, 9-5) = 4 (DR ate 2 of the
fire dice); the fix reduces only the 3 piercing damage (floored at 0) and
the full 6 fire damage lands.
"""
weapon = _attack(
"flintlock",
(("1d8", ("piercing",)), ("1d6", ("fire",))),
)
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe")
foe = foe.model_copy(update={"dr": DamageReduction(amount=5)})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 3, 6])
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_damage_vs_dr_unchanged() -> None:
"""(h) a physical-only weapon keeps the exact old DR arithmetic (regression
lock for the split): 1d8 = 3 vs DR 5 reduces below 1 - the CRB "Damage"
Minimum Damage floor then leaves a landed hit dealing 1 point of
NONLETHAL damage (SRD "Nonlethal Damage": the floor never touches hp)."""
weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),))
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 3])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
assert result.damage == 0
assert result.nonlethal == 1
assert eng._states[1].hp == 100 # the Minimum Damage floor is nonlethal
assert eng._states[1].nonlethal == 1