fix(data): lich fear aura, paralyzing touch, slot keys

This commit is contained in:
2026-09-04 22:56:01 +02:00
parent 364e28e2c0
commit 9f9bd27ca8
3 changed files with 265 additions and 16 deletions
+190
View File
@@ -0,0 +1,190 @@
"""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",
abilities=AbilityScores(
str_score=12, dex_score=14, con_score=14,
int_score=20, wis_score=14, cha_score=16,
),
hp_max=100,
ac=ACProfile(total=25, touch=14, flat_footed=23),
bab=8,
initiative_mod=6,
speed_land_ft=30,
saves=Saves(fort=10, ref=10, will=14),
attacks=[
AttackSpec(
id="lich:touch",
name="Paralyzing Touch",
kind="touch",
attack_bonus=10,
damage=[DamageComponent(formula=parse_dice("1d8+5"), types=["negative"])],
damage_bonus=5,
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)
# ── 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