fix(data): lich fear aura, paralyzing touch, slot keys
This commit is contained in:
+7
-16
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user