"""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 30 ft at the start of the Lich's turn When: it fails its Will save (DC 22) 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 22 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_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 >= 22 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", and the template's "Being undead, a lich has no Constitution score") Then: BAB, the natural touch attack, AC, saves and CON 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} 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