From 9f9bd27ca816a12f395fa0859f1888fd65cbaaba Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:56:01 +0200 Subject: [PATCH] fix(data): lich fear aura, paralyzing touch, slot keys --- data/monsters/lich.json | 23 ++--- src/pf1e_simulator/combat.py | 68 +++++++++++++ tests/test_lich.py | 190 +++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 tests/test_lich.py diff --git a/data/monsters/lich.json b/data/monsters/lich.json index 5719cc5..09bdc80 100644 --- a/data/monsters/lich.json +++ b/data/monsters/lich.json @@ -25,13 +25,13 @@ "will": 14 }, "casting_ability": "int", - "resources": { - "spell_slots_1": 4, - "spell_slots_2": 4, - "spell_slots_3": 4, - "spell_slots_4": 4, - "spell_slots_5": 4, - "spell_slots_6": 3 + "spell_slots": { + "1": 4, + "2": 4, + "3": 4, + "4": 4, + "5": 4, + "6": 3 }, "attacks": [ { @@ -76,15 +76,6 @@ "Horrid Wilting", "Power Word Kill" ], - "casting_ability": "int", - "resources": { - "spell_slots_1": 4, - "spell_slots_2": 4, - "spell_slots_3": 4, - "spell_slots_4": 4, - "spell_slots_5": 4, - "spell_slots_6": 3 - }, "metamagic_feats": [], "skills": {} } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 66fc9f4..00b31ca 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -373,6 +373,7 @@ class CombatEngine: if self.has_condition_flag(state, "cant_act"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)") return self._winner_side() is not None + self._apply_fear_aura(state) state.moved_this_turn = False state.power_attack = False actions = self._policy(self, state) @@ -590,6 +591,62 @@ class CombatEngine: """True if the combatant has a feature with the given name.""" return any(f.name == feat_name for f in state.combatant.features) + def _is_paralyzing_touch(self, attacker: CombatantState, weapon: AttackSpec) -> bool: + """True if a hit with this weapon should apply the Lich's paralyzing touch. + + Robust check: the attacker has the "Paralyzing Touch" feature and the + weapon is a touch attack (or is literally named "Paralyzing Touch"). + """ + if not self._has_feat(attacker, "Paralyzing Touch"): + return False + return weapon.kind == "touch" or weapon.name == "Paralyzing Touch" + + _FEAR_AURA_DC = 22 + _FEAR_AURA_RANGE_FT = 30 + _FEAR_AURA_ROUNDS = 1 + + def _apply_fear_aura(self, state: CombatantState) -> None: + """Apply the Lich's Fear Aura: enemies within 30 ft save Will (DC 22) or become shaken. + + Triggered at the start of the aura-bearer's turn (simplification of the + PF1e "on range entry" trigger — the lich acts first each round, so this + covers entry into range). Only enemies within range who are not already + shaken are tested, so the effect does not re-roll on the same enemy each + turn. Shaken lasts a fixed 1 round. + """ + if not self._has_feat(state, "Fear Aura"): + return + for enemy in self._states: + if ( + not enemy.active + or enemy.side == state.side + or self._has_condition_named(enemy, "shaken") + ): + continue + dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT + if dist_ft > self._FEAR_AURA_RANGE_FT: + continue + save = self.resolve_save(enemy, "will", self._FEAR_AURA_DC) + if save.success: + self._log( + f"round {self._current_round} {state.combatant.id}: fear aura on " + f"{enemy.combatant.id} -> SAVED" + ) + continue + enemy.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["shaken"], + rounds_left=self._FEAR_AURA_ROUNDS, + source=f"monster ability: fear aura by {state.combatant.id}", + ) + ) + self._log( + f"round {self._current_round} {state.combatant.id}: fear aura on " + f"{enemy.combatant.id} -> shaken ({self._FEAR_AURA_ROUNDS}r)" + ) + + + def _aoo_allowance(self, state: CombatantState) -> int: """Number of AoOs allowed per round for a combatant. @@ -2052,6 +2109,17 @@ class CombatEngine: ) if result.hit: line += f" {result.damage} damage ({hp_before}->{target.hp})" + if self._is_paralyzing_touch(attacker, weapon): + duration = 1 + self._rng.roll_die(4) + if not self._has_condition_named(target, "paralyzed"): + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["paralyzed"], + rounds_left=duration, + source=f"monster ability: paralyzing touch by {attacker.combatant.id}", + ) + ) + line += f" paralyzed ({duration}r)" self._log(line) if target.dead: self._log( diff --git a/tests/test_lich.py b/tests/test_lich.py new file mode 100644 index 0000000..77528e1 --- /dev/null +++ b/tests/test_lich.py @@ -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