"""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