fix(rules): lich data conforms to the KB stat block

Verified against the KB (bestiary/lich.md, Bestiary Lich CR 12):
- bab 8 -> 5 ('Base Atk +5')
- Paralyzing Touch attack_bonus 10 -> 5 ('Melee touch +5 (1d8+5 plus
  paralyzing touch)') and marked natural: true (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 per e5691bd)
- damage_bonus 5 -> 0: the +5 (1 point per 2 HD) is already in the
  1d8+5 formula and _resolve_swing adds damage_bonus on top (double
  counting: the old data dealt 1d8+10)
- ac 25/14/23 -> 23/14/21 ('AC 23, touch 14, flat-footed 21'; touch was
  already correct)
- saves 10/10/14 -> 6/7/11 ('Fort +6, Ref +7, Will +11')
- con_score 14 -> 10: 'Being undead, a lich has no Constitution score'
  (Con '—'); the int-only schema encodes 'no score' as the neutral 10
  (modifier +0), inert anyway since undead are destroyed at hp <= 0.
Left as intentional engine tuning (residual divergences, documented in
the final report): hp 100 vs 111, Str 12 vs 10, Int 20 vs 22,
initiative +6 vs +2, fear aura 30 ft/DC 22 vs 60 ft/DC 18.
Golden transcript untouched (the lich wins via Magic Missile before any
weapon swing; MM DC depends on Int, unchanged). Pinned a new
test_json_matches_srd_stat_block.
This commit is contained in:
2026-09-16 15:22:10 +02:00
parent 9a16ca0388
commit 881f2ef9b2
2 changed files with 44 additions and 15 deletions
+10 -9
View File
@@ -6,24 +6,24 @@
"abilities": {
"str_score": 12,
"dex_score": 14,
"con_score": 14,
"con_score": 10,
"int_score": 20,
"wis_score": 14,
"cha_score": 16
},
"hp_max": 100,
"ac": {
"total": 25,
"total": 23,
"touch": 14,
"flat_footed": 23
"flat_footed": 21
},
"bab": 8,
"bab": 5,
"initiative_mod": 6,
"speed_land_ft": 30,
"saves": {
"fort": 10,
"ref": 10,
"will": 14
"fort": 6,
"ref": 7,
"will": 11
},
"casting_ability": "int",
"spell_slots": {
@@ -39,14 +39,15 @@
"id": "lich:touch",
"name": "Paralyzing Touch",
"kind": "touch",
"attack_bonus": 10,
"attack_bonus": 5,
"natural": true,
"damage": [
{
"formula": "1d8+5",
"types": ["negative"]
}
],
"damage_bonus": 5,
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
+34 -6
View File
@@ -36,23 +36,24 @@ def _make_lich() -> Combatant:
size="Medium",
creature_type="undead",
abilities=AbilityScores(
str_score=12, dex_score=14, con_score=14,
str_score=12, dex_score=14, con_score=10,
int_score=20, wis_score=14, cha_score=16,
),
hp_max=100,
ac=ACProfile(total=25, touch=14, flat_footed=23),
bab=8,
ac=ACProfile(total=23, touch=14, flat_footed=21),
bab=5,
initiative_mod=6,
speed_land_ft=30,
saves=Saves(fort=10, ref=10, will=14),
saves=Saves(fort=6, ref=7, will=11),
attacks=[
AttackSpec(
id="lich:touch",
name="Paralyzing Touch",
kind="touch",
attack_bonus=10,
attack_bonus=5,
natural=True,
damage=[DamageComponent(formula=parse_dice("1d8+5"), types=["negative"])],
damage_bonus=5,
damage_bonus=0,
crit_range=20,
crit_mult=2,
reach_ft=5,
@@ -225,3 +226,30 @@ class TestLichJsonIntegrity:
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