293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""Tests for the Lich's Fear Aura, Paralyzing Touch, and clean JSON.
|
|
|
|
Given: a Lich monster with the "Fear Aura" and "Paralyzing Touch" features.
|
|
When: it lands a Paralyzing Touch hit, or begins its turn with enemies in range.
|
|
Then: those enemies gain the paralyzed / shaken active conditions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from pf1e_simulator.abilities import AbilitySpec
|
|
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,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
|
|
_DATA = Path(__file__).resolve().parents[1] / "data" / "monsters" / "lich.json"
|
|
|
|
|
|
def _make_lich() -> Combatant:
|
|
return Combatant(
|
|
id="lich",
|
|
name="Lich",
|
|
level=11,
|
|
size="Medium",
|
|
creature_type="undead",
|
|
abilities=AbilityScores(
|
|
str_score=12, dex_score=14, con_score=10,
|
|
int_score=20, wis_score=14, cha_score=16,
|
|
),
|
|
hp_max=100,
|
|
ac=ACProfile(total=23, touch=14, flat_footed=21),
|
|
bab=5,
|
|
initiative_mod=6,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=6, ref=7, will=11),
|
|
attacks=[
|
|
AttackSpec(
|
|
id="lich:touch",
|
|
name="Paralyzing Touch",
|
|
kind="touch",
|
|
attack_bonus=5,
|
|
natural=True,
|
|
damage=[DamageComponent(formula=parse_dice("1d8+5"), types=["negative"])],
|
|
damage_bonus=0,
|
|
crit_range=20,
|
|
crit_mult=2,
|
|
reach_ft=5,
|
|
count=1,
|
|
)
|
|
],
|
|
features=[
|
|
AbilitySpec(name="Fear Aura", category="class"),
|
|
AbilitySpec(name="Paralyzing Touch", category="class"),
|
|
],
|
|
)
|
|
|
|
|
|
def _make_foe(cid: str = "foe", hp: int = 50, ac: int = 13, will: int = 0) -> Combatant:
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=12,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=hp,
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=1,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=0, ref=0, will=will),
|
|
attacks=[
|
|
AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="sword",
|
|
kind="melee",
|
|
attack_bonus=1,
|
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
|
|
crit_range=20,
|
|
crit_mult=2,
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def _make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
|
return CombatantState(
|
|
combatant=combatant, side=side, pos=pos, hp=combatant.hp_max
|
|
)
|
|
|
|
|
|
def _make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
return CombatEngine(ScriptedRng(queue), grid, states, round_cap=100)
|
|
|
|
|
|
# ── Paralyzing Touch ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestParalyzingTouch:
|
|
"""Given: a touch hit lands on the target
|
|
When: the attacker has the Paralyzing Touch feature
|
|
Then: the target gains the paralyzed active condition (cant_act)."""
|
|
|
|
def test_hit_applies_paralyzed_condition(self) -> None:
|
|
lich = _make_lich()
|
|
foe = _make_foe()
|
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
|
foe_state = _make_state(foe, "players", (1, 2))
|
|
engine = _make_engine(
|
|
[12, 3, 2], [lich_state, foe_state]
|
|
) # d20=12 hit (no crit confirm), 1d8+5 damage=3, 1d4=2 -> 3 rounds
|
|
engine._resolve_swing(
|
|
lich_state, foe_state, lich_state.combatant.attacks[0], label="Paralyzing Touch"
|
|
)
|
|
par = [ac for ac in foe_state.active_conditions if ac.condition.name == "paralyzed"]
|
|
assert len(par) == 1
|
|
assert par[0].rounds_left == 3
|
|
assert par[0].condition.cant_act is True
|
|
|
|
def test_miss_does_not_apply_paralyzed(self) -> None:
|
|
lich = _make_lich()
|
|
foe = _make_foe(ac=30)
|
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
|
foe_state = _make_state(foe, "players", (1, 2))
|
|
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 miss (low bonus vs AC 30)
|
|
engine._resolve_swing(
|
|
lich_state, foe_state, lich_state.combatant.attacks[0], label="Paralyzing Touch"
|
|
)
|
|
assert all(ac.condition.name != "paralyzed" for ac in foe_state.active_conditions)
|
|
|
|
|
|
# ── Fear Aura ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestFearAura:
|
|
"""Given: an enemy within 60 ft at the start of the Lich's turn
|
|
When: it fails its Will save (DC 18, KB bestiary/lich.md "Aura fear
|
|
(60-ft. radius, DC 18)")
|
|
Then: the enemy gains the shaken active condition."""
|
|
|
|
def test_failed_save_applies_shaken(self) -> None:
|
|
lich = _make_lich()
|
|
foe = _make_foe(will=0)
|
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
|
foe_state = _make_state(foe, "players", (1, 2)) # adjacent: 5 ft
|
|
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 -> fails Will DC 18
|
|
engine._apply_fear_aura(lich_state)
|
|
shaken = [ac for ac in foe_state.active_conditions if ac.condition.name == "shaken"]
|
|
assert len(shaken) == 1
|
|
assert shaken[0].condition.modifiers
|
|
|
|
def test_enemy_beyond_60ft_not_tested(self) -> None:
|
|
"""KB radius is 60 ft: an enemy at 75 ft (15 cells diagonal) is outside
|
|
the aura - no save die is consumed (empty queue would raise if it were)."""
|
|
lich = _make_lich()
|
|
foe = _make_foe(will=0)
|
|
lich_state = _make_state(lich, "monsters", (0, 0))
|
|
foe_state = _make_state(foe, "players", (10, 10)) # 75 ft away
|
|
engine = _make_engine([], [lich_state, foe_state])
|
|
engine._apply_fear_aura(lich_state)
|
|
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
|
|
|
|
def test_enemy_at_60ft_is_tested(self) -> None:
|
|
"""An enemy exactly at 60 ft (12 cells straight) is inside the aura."""
|
|
lich = _make_lich()
|
|
foe = _make_foe(will=0)
|
|
lich_state = _make_state(lich, "monsters", (0, 0))
|
|
foe_state = _make_state(foe, "players", (0, 12)) # 60 ft away
|
|
engine = _make_engine([2], [lich_state, foe_state]) # fails Will DC 18
|
|
engine._apply_fear_aura(lich_state)
|
|
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
|
|
|
def test_successful_save_does_not_apply_shaken(self) -> None:
|
|
lich = _make_lich()
|
|
foe = _make_foe(will=30)
|
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
|
foe_state = _make_state(foe, "players", (1, 2))
|
|
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 + will 30 >= 18
|
|
engine._apply_fear_aura(lich_state)
|
|
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
|
|
|
|
|
|
# ── Undead death threshold ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestUndeadDeathThreshold:
|
|
"""Given: an undead creature (no CON score, SRD Undead traits)
|
|
When: its hp reaches 0 or below
|
|
Then: it is immediately destroyed (dead), unlike a living creature
|
|
which keeps the disabled band at 0 and the dying band down to -CON."""
|
|
|
|
def test_undead_dead_at_zero(self) -> None:
|
|
lich = _make_lich()
|
|
state = _make_state(lich, "monsters", (1, 1))
|
|
state.hp = 0
|
|
assert state.dead is True
|
|
assert state.active is False
|
|
|
|
def test_undead_dead_below_zero(self) -> None:
|
|
lich = _make_lich()
|
|
state = _make_state(lich, "monsters", (1, 1))
|
|
state.hp = -1
|
|
assert state.dead is True
|
|
|
|
def test_living_not_dead_at_zero(self) -> None:
|
|
foe = _make_foe()
|
|
state = _make_state(foe, "players", (1, 1))
|
|
state.hp = 0
|
|
assert state.dead is False
|
|
assert state.active is True
|
|
|
|
def test_living_not_dead_in_dying_band(self) -> None:
|
|
foe = _make_foe()
|
|
state = _make_state(foe, "players", (1, 1))
|
|
state.hp = -1 # -1 > -CON (12)
|
|
assert state.dead is False
|
|
|
|
|
|
# ── JSON integrity ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestLichJsonIntegrity:
|
|
"""Given: the lich bestiary JSON
|
|
When: parsed
|
|
Then: it is valid, has no duplicate keys, and no legacy spell_slots_N keys."""
|
|
|
|
def test_json_is_clean(self) -> None:
|
|
raw = _DATA.read_text(encoding="utf-8")
|
|
data = json.loads(raw)
|
|
assert any(k == "spell_slots" for k in data)
|
|
assert not any("spell_slots_" in k for k in data)
|
|
assert len(data["casting_ability"]) > 0
|
|
assert data["spell_slots"]["6"] == 3
|
|
|
|
def test_json_matches_srd_stat_block(self) -> None:
|
|
"""Given: the lich bestiary JSON
|
|
When: checked against the KB stat block (Bestiary "Lich":
|
|
"Base Atk +5", "Melee touch +5 (1d8+5 plus paralyzing touch)",
|
|
"AC 23, touch 14, flat-footed 21", "Fort +6, Ref +7, Will +11",
|
|
"hp 111 (11d6+55 plus 15 false life)", "**Init** +2",
|
|
"**Aura** fear (60-ft. radius, DC 18)",
|
|
"**Str** 10, **Dex** 14, **Con** —, **Int** 22, **Wis** 14, **Cha** 16",
|
|
and the template's "Being undead, a lich has no Constitution score")
|
|
Then: BAB, the natural touch attack, AC, saves, CON, hp, Str, Int,
|
|
initiative and the fear aura constants all match."""
|
|
data = json.loads(_DATA.read_text(encoding="utf-8"))
|
|
assert data["bab"] == 5
|
|
assert data["ac"] == {"total": 23, "touch": 14, "flat_footed": 21}
|
|
assert data["saves"] == {"fort": 6, "ref": 7, "will": 11}
|
|
# KB bestiary/lich.md gives hp 111 (11d6+55 plus 15 false life).
|
|
assert data["hp_max"] == 111
|
|
# KB Statistics: "Str 10, Dex 14, Con —, Int 22, Wis 14, Cha 16"
|
|
assert data["abilities"]["str_score"] == 10
|
|
assert data["abilities"]["int_score"] == 22
|
|
# KB bestiary/lich.md gives a +2 initiative.
|
|
assert data["initiative_mod"] == 2
|
|
# KB: "**Aura** fear (60-ft. radius, DC 18)" - the engine implements
|
|
# the aura as feature-keyed constants (only the lich carries it).
|
|
assert CombatEngine._FEAR_AURA_RANGE_FT == 60
|
|
assert CombatEngine._FEAR_AURA_DC == 18
|
|
touch = data["attacks"][0]
|
|
assert touch["attack_bonus"] == 5
|
|
# SRD lich template: "A lich has a touch attack that it can use once
|
|
# per round as a natural weapon [...] treated as a primary natural
|
|
# weapon" - no BAB iteratives (CRB "Natural Attacks").
|
|
assert touch["natural"] is True
|
|
assert touch["damage"][0]["formula"] == "1d8+5"
|
|
# The +5 (1 point per 2 HD, SRD "Damage") is already in the formula;
|
|
# damage_bonus would be added on top (double counting).
|
|
assert touch["damage_bonus"] == 0
|
|
# Undead have no CON score: encoded as the neutral 10 (modifier +0);
|
|
# the engine destroys undead at hp <= 0 (SRD Undead traits), so the
|
|
# score is never used for the dying band.
|
|
assert data["creature_type"] == "undead"
|
|
assert data["abilities"]["con_score"] == 10
|