249 lines
6.8 KiB
Python
249 lines
6.8 KiB
Python
"""Spell resistance and energy resistance/immunity (Package P1)."""
|
|
from __future__ import annotations
|
|
|
|
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 _snapshot, make_grid, make_state
|
|
|
|
|
|
def _make_combatant(
|
|
id_: str,
|
|
*,
|
|
hp_max: int = 100,
|
|
attacks: tuple[AttackSpec, ...] = (),
|
|
spell_resistance: int = 0,
|
|
resistances: dict[str, int] | None = None,
|
|
immunities: tuple[str, ...] = (),
|
|
) -> Combatant:
|
|
return Combatant(
|
|
id=id_,
|
|
name=id_,
|
|
level=1,
|
|
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=hp_max,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=list(attacks),
|
|
spell_resistance=spell_resistance,
|
|
resistances=resistances or {},
|
|
immunities=immunities,
|
|
)
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
_MAGIC_MISSILE = SpellSpec(
|
|
name="Magic Missile",
|
|
level=1,
|
|
school="evocation",
|
|
range="medium",
|
|
save="none",
|
|
base_dc=10,
|
|
effects=(
|
|
DamageEffect(
|
|
formula=parse_dice("1d4+1"),
|
|
types=("force",),
|
|
),
|
|
),
|
|
)
|
|
|
|
_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_sr_blocks_all_effects() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
target = _make_combatant("target", spell_resistance=15)
|
|
eng = _engine(
|
|
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
|
|
[8],
|
|
spell_registry={"Magic Missile": _MAGIC_MISSILE},
|
|
)
|
|
cs = eng._states[0]
|
|
ts = eng._states[1]
|
|
eng._cast_spell(cs, ts, "Magic Missile")
|
|
assert ts.hp == 100
|
|
|
|
|
|
def test_sr_passes_when_cl_suffices() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
target = _make_combatant("target", spell_resistance=10)
|
|
eng = _engine(
|
|
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
|
|
[10, 3],
|
|
spell_registry={"Magic Missile": _MAGIC_MISSILE},
|
|
)
|
|
cs = eng._states[0]
|
|
ts = eng._states[1]
|
|
eng._cast_spell(cs, ts, "Magic Missile")
|
|
assert ts.hp < 100
|
|
|
|
|
|
def test_per_target_sr_in_area_spell() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
t1 = _make_combatant("t1", spell_resistance=15)
|
|
t2 = _make_combatant("t2", spell_resistance=0)
|
|
eng = _engine(
|
|
[caster, t1, t2],
|
|
[(0, 0), (0, 3), (0, 5)],
|
|
["players", "monsters", "monsters"],
|
|
[20, 3, 4, 4, 4, 4, 4, 4, 4, 4],
|
|
spell_registry={"Fireball": _FIREBALL},
|
|
)
|
|
cs = eng._states[0]
|
|
ts1 = eng._states[1]
|
|
ts2 = eng._states[2]
|
|
eng._cast_spell(cs, ts2, "Fireball")
|
|
assert ts1.hp == 100
|
|
assert ts2.hp < 100
|
|
|
|
|
|
def test_no_sr_check_when_sr_zero() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
target = _make_combatant("target", spell_resistance=0)
|
|
eng = _engine(
|
|
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
|
|
[3],
|
|
spell_registry={"Magic Missile": _MAGIC_MISSILE},
|
|
)
|
|
cs = eng._states[0]
|
|
ts = eng._states[1]
|
|
eng._cast_spell(cs, ts, "Magic Missile")
|
|
assert ts.hp == 96
|
|
assert not any("spell resistance" in line for line in eng._transcript)
|
|
|
|
|
|
def test_fire_resistance_reduces_spell_damage() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
target = _make_combatant(
|
|
"target",
|
|
spell_resistance=0,
|
|
resistances={"fire": 5},
|
|
)
|
|
eng = _engine(
|
|
[caster, target], [(0, 0), (0, 5)], ["players", "monsters"],
|
|
[1, 3, 3, 3, 3, 3, 3, 3, 3],
|
|
spell_registry={"Fireball": _FIREBALL},
|
|
)
|
|
cs = eng._states[0]
|
|
ts = eng._states[1]
|
|
eng._cast_spell(cs, ts, "Fireball")
|
|
assert ts.hp < 100
|
|
|
|
|
|
def test_fire_immunity_negates_damage() -> None:
|
|
caster = _make_combatant("caster", spell_resistance=0)
|
|
target = _make_combatant(
|
|
"target",
|
|
spell_resistance=0,
|
|
immunities=("fire",),
|
|
)
|
|
eng = _engine(
|
|
[caster, target], [(0, 0), (0, 5)], ["players", "monsters"],
|
|
[1, 3, 3, 3, 3, 3, 3, 3, 3],
|
|
spell_registry={"Fireball": _FIREBALL},
|
|
)
|
|
cs = eng._states[0]
|
|
ts = eng._states[1]
|
|
eng._cast_spell(cs, ts, "Fireball")
|
|
assert ts.hp == 100
|
|
|
|
|
|
def test_weapon_fire_damage_reduced_by_resistance() -> None:
|
|
fire_weapon = AttackSpec(
|
|
id="flaming-sword",
|
|
name="flaming sword",
|
|
kind="melee",
|
|
attack_bonus=20,
|
|
damage=(
|
|
DamageComponent(formula=parse_dice("1d8"), types=("slashing",)),
|
|
DamageComponent(formula=parse_dice("1d6"), types=("fire",)),
|
|
),
|
|
damage_bonus=0,
|
|
crit_range=20,
|
|
crit_mult=2,
|
|
)
|
|
hero = _make_combatant("hero", attacks=(fire_weapon,))
|
|
foe = _make_combatant(
|
|
"foe",
|
|
resistances={"fire": 10},
|
|
)
|
|
eng = _engine(
|
|
[hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
|
[15, 7, 6],
|
|
)
|
|
hs = eng._states[0]
|
|
fs = eng._states[1]
|
|
eng._single_attack(hs, fs, fire_weapon)
|
|
assert fs.hp < 100
|
|
|
|
|
|
def test_snapshot_exposes_new_keys() -> None:
|
|
hero = _make_combatant(
|
|
"hero",
|
|
spell_resistance=15,
|
|
resistances={"fire": 10, "cold": 5},
|
|
immunities=("electricity",),
|
|
)
|
|
foe = _make_combatant("foe")
|
|
eng = _engine(
|
|
[hero, foe], [(0, 0), (0, 3)], ["players", "monsters"],
|
|
[],
|
|
)
|
|
hs = eng._states[0]
|
|
obs = _snapshot(eng, hs)
|
|
c = obs["combatants"][0]
|
|
assert c["spell_resistance"] == 15
|
|
assert c["resistances"] == {"fire": 10, "cold": 5}
|
|
assert c["immunities"] == ("electricity",)
|