fix(rules): lich stat block aligned to the KB entry - bestiary/lich.md gives hp 111 (11d6+55 plus 15 false life), Str 10, Int 22, Init +2 and 'Aura fear (60-ft. radius, DC 18)'; the JSON carried scenario-tuned 100 hp / Str 12 / Int 20 / Init +6 and the engine's feature-keyed aura constants carried 30 ft / DC 22 (BAB/touch/AC/saves/CON already conformed); the sim's own spell list is kept (the KB's prepared list includes spells outside data/spells/) and the choice is documented in the JSON notes and the README; golden tammara-vs-lich re-pinned for the two SRD-correct line changes (initiative d20=10+2=12 from the KB +2, Magic Missile DC17 = 10+1+6 from Int 22; damage unchanged - the 75 ft deployment distance stays beyond even the widened 60 ft aura, so no save die is consumed); +2 aura range tests (75 ft outside, 60 ft inside)
This commit is contained in:
@@ -1646,14 +1646,20 @@ Règles modélisées :
|
|||||||
moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient
|
moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient
|
||||||
lieu (surcouverture : les effets de compulsion comptent aussi))
|
lieu (surcouverture : les effets de compulsion comptent aussi))
|
||||||
— couvertes par `tests/test_class_abilities.py`.
|
— couvertes par `tests/test_class_abilities.py`.
|
||||||
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
|
- Le Liche (`data/monsters/lich.json`) : bloc de stats aligné sur la fiche KB
|
||||||
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
|
`bestiary/lich.md` (PV 111, For 10, Int 22, Init +2, DD et bonus déjà
|
||||||
|
conformes), `creature_type: "undead"` (sensible à l'énergie
|
||||||
|
positive canalisée), aura de terreur (60 ft, Volonté DD 18 ou
|
||||||
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
|
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
|
||||||
`paralysé` 1d4+1 rounds), RD 15/contondant **et** magie (conformance P2 :
|
`paralysé` 1d4+1 rounds), RD 15/contondant **et** magie (conformance P2 :
|
||||||
les deux composants sont requis — une arme contondante non magique ne
|
les deux composants sont requis — une arme contondante non magique ne
|
||||||
contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de
|
contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de
|
||||||
propriété « magique », donc aucun équipement ne contourne cette RD) ; son JSON porte un dictionnaire
|
propriété « magique », donc aucun équipement ne contourne cette RD) ; son JSON porte un dictionnaire
|
||||||
`spell_slots` sans clés dupliquées.
|
`spell_slots` sans clés dupliquées. Choix assumé : la liste de sorts reste
|
||||||
|
la liste d'encounter du simulateur (la liste préparée de la fiche KB
|
||||||
|
contient des sorts absents du registre `data/spells/`) — seul le bloc de
|
||||||
|
stats a été aligné ; la liste de sorts du registre est donc une
|
||||||
|
approximation documentée, pas une transcription SRD.
|
||||||
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
|
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
|
||||||
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
|
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
|
||||||
JSON) isolée du combat — le snapshot est la SEULE source d'info de la LLM
|
JSON) isolée du combat — le snapshot est la SEULE source d'info de la LLM
|
||||||
|
|||||||
@@ -4,21 +4,21 @@
|
|||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"creature_type": "undead",
|
"creature_type": "undead",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"str_score": 12,
|
"str_score": 10,
|
||||||
"dex_score": 14,
|
"dex_score": 14,
|
||||||
"con_score": 10,
|
"con_score": 10,
|
||||||
"int_score": 20,
|
"int_score": 22,
|
||||||
"wis_score": 14,
|
"wis_score": 14,
|
||||||
"cha_score": 16
|
"cha_score": 16
|
||||||
},
|
},
|
||||||
"hp_max": 100,
|
"hp_max": 111,
|
||||||
"ac": {
|
"ac": {
|
||||||
"total": 23,
|
"total": 23,
|
||||||
"touch": 14,
|
"touch": 14,
|
||||||
"flat_footed": 21
|
"flat_footed": 21
|
||||||
},
|
},
|
||||||
"bab": 5,
|
"bab": 5,
|
||||||
"initiative_mod": 6,
|
"initiative_mod": 2,
|
||||||
"speed_land_ft": 30,
|
"speed_land_ft": 30,
|
||||||
"saves": {
|
"saves": {
|
||||||
"fort": 6,
|
"fort": 6,
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"bypass": ["bludgeoning", "magic"]
|
"bypass": ["bludgeoning", "magic"]
|
||||||
},
|
},
|
||||||
"source": "bestiary",
|
"source": "bestiary",
|
||||||
|
"notes": "Stat block aligned to the KB lich entry (bestiary/lich.md): hp 111 (11d6+55 plus 15 false life), Str 10, Int 22, Init +2, fear aura 60 ft / DC 18. The spell list is the sim's own encounter-authored list (the KB's prepared list includes spells outside the data/spells registry); only the stat block was aligned.",
|
||||||
"features": [
|
"features": [
|
||||||
"Fear Aura",
|
"Fear Aura",
|
||||||
"Paralyzing Touch",
|
"Paralyzing Touch",
|
||||||
|
|||||||
@@ -2802,12 +2802,13 @@ class CombatEngine:
|
|||||||
return False
|
return False
|
||||||
return weapon.kind == "touch" or weapon.name == "Paralyzing Touch"
|
return weapon.kind == "touch" or weapon.name == "Paralyzing Touch"
|
||||||
|
|
||||||
_FEAR_AURA_DC = 22
|
# KB bestiary/lich.md: "Aura fear (60-ft. radius, DC 18)".
|
||||||
_FEAR_AURA_RANGE_FT = 30
|
_FEAR_AURA_DC = 18
|
||||||
|
_FEAR_AURA_RANGE_FT = 60
|
||||||
_FEAR_AURA_ROUNDS = 1
|
_FEAR_AURA_ROUNDS = 1
|
||||||
|
|
||||||
def _apply_fear_aura(self, state: CombatantState) -> None:
|
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.
|
"""Apply the Lich's Fear Aura: enemies within 60 ft save Will (DC 18) or become shaken.
|
||||||
|
|
||||||
Triggered at the start of the aura-bearer's turn (simplification of the
|
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
|
PF1e "on range entry" trigger — the lich acts first each round, so this
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ def test_fear_aura_save_carries_fear_descriptor() -> None:
|
|||||||
foe = make_combatant("foe", will=0, features=(_FEAR_SAVE_PROBE,))
|
foe = make_combatant("foe", will=0, features=(_FEAR_SAVE_PROBE,))
|
||||||
lich_state = make_state(lich, "monsters", (1, 1))
|
lich_state = make_state(lich, "monsters", (1, 1))
|
||||||
foe_state = make_state(foe, "players", (1, 2)) # 5 ft, inside aura range
|
foe_state = make_state(foe, "players", (1, 2)) # 5 ft, inside aura range
|
||||||
engine = make_engine([21], [lich_state, foe_state]) # 21 + 0 + 1 = 22 >= DC 22
|
engine = make_engine([17], [lich_state, foe_state]) # 17 + 0 + 1 = 18 >= DC 18
|
||||||
engine._apply_fear_aura(lich_state)
|
engine._apply_fear_aura(lich_state)
|
||||||
assert not any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
assert not any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ def test_fear_aura_without_fear_bonus_fails_save() -> None:
|
|||||||
foe = make_combatant("foe", will=0)
|
foe = make_combatant("foe", will=0)
|
||||||
lich_state = make_state(lich, "monsters", (1, 1))
|
lich_state = make_state(lich, "monsters", (1, 1))
|
||||||
foe_state = make_state(foe, "players", (1, 2))
|
foe_state = make_state(foe, "players", (1, 2))
|
||||||
engine = make_engine([21], [lich_state, foe_state]) # 21 < DC 22
|
engine = make_engine([17], [lich_state, foe_state]) # 17 < DC 18
|
||||||
engine._apply_fear_aura(lich_state)
|
engine._apply_fear_aura(lich_state)
|
||||||
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
|
|||||||
+42
-5
@@ -150,8 +150,9 @@ class TestParalyzingTouch:
|
|||||||
|
|
||||||
|
|
||||||
class TestFearAura:
|
class TestFearAura:
|
||||||
"""Given: an enemy within 30 ft at the start of the Lich's turn
|
"""Given: an enemy within 60 ft at the start of the Lich's turn
|
||||||
When: it fails its Will save (DC 22)
|
When: it fails its Will save (DC 18, KB bestiary/lich.md "Aura fear
|
||||||
|
(60-ft. radius, DC 18)")
|
||||||
Then: the enemy gains the shaken active condition."""
|
Then: the enemy gains the shaken active condition."""
|
||||||
|
|
||||||
def test_failed_save_applies_shaken(self) -> None:
|
def test_failed_save_applies_shaken(self) -> None:
|
||||||
@@ -159,18 +160,39 @@ class TestFearAura:
|
|||||||
foe = _make_foe(will=0)
|
foe = _make_foe(will=0)
|
||||||
lich_state = _make_state(lich, "monsters", (1, 1))
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
||||||
foe_state = _make_state(foe, "players", (1, 2)) # adjacent: 5 ft
|
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 = _make_engine([2], [lich_state, foe_state]) # d20=2 -> fails Will DC 18
|
||||||
engine._apply_fear_aura(lich_state)
|
engine._apply_fear_aura(lich_state)
|
||||||
shaken = [ac for ac in foe_state.active_conditions if ac.condition.name == "shaken"]
|
shaken = [ac for ac in foe_state.active_conditions if ac.condition.name == "shaken"]
|
||||||
assert len(shaken) == 1
|
assert len(shaken) == 1
|
||||||
assert shaken[0].condition.modifiers
|
assert shaken[0].condition.modifiers
|
||||||
|
|
||||||
|
def test_enemy_beyond_60ft_not_tested(self) -> None:
|
||||||
|
"""KB radius is 60 ft: an enemy at 75 ft (15 cells diagonal) is outside
|
||||||
|
the aura - no save die is consumed (empty queue would raise if it were)."""
|
||||||
|
lich = _make_lich()
|
||||||
|
foe = _make_foe(will=0)
|
||||||
|
lich_state = _make_state(lich, "monsters", (0, 0))
|
||||||
|
foe_state = _make_state(foe, "players", (10, 10)) # 75 ft away
|
||||||
|
engine = _make_engine([], [lich_state, foe_state])
|
||||||
|
engine._apply_fear_aura(lich_state)
|
||||||
|
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
|
def test_enemy_at_60ft_is_tested(self) -> None:
|
||||||
|
"""An enemy exactly at 60 ft (12 cells straight) is inside the aura."""
|
||||||
|
lich = _make_lich()
|
||||||
|
foe = _make_foe(will=0)
|
||||||
|
lich_state = _make_state(lich, "monsters", (0, 0))
|
||||||
|
foe_state = _make_state(foe, "players", (0, 12)) # 60 ft away
|
||||||
|
engine = _make_engine([2], [lich_state, foe_state]) # fails Will DC 18
|
||||||
|
engine._apply_fear_aura(lich_state)
|
||||||
|
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
def test_successful_save_does_not_apply_shaken(self) -> None:
|
def test_successful_save_does_not_apply_shaken(self) -> None:
|
||||||
lich = _make_lich()
|
lich = _make_lich()
|
||||||
foe = _make_foe(will=30)
|
foe = _make_foe(will=30)
|
||||||
lich_state = _make_state(lich, "monsters", (1, 1))
|
lich_state = _make_state(lich, "monsters", (1, 1))
|
||||||
foe_state = _make_state(foe, "players", (1, 2))
|
foe_state = _make_state(foe, "players", (1, 2))
|
||||||
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 + will 30 >= 22
|
engine = _make_engine([2], [lich_state, foe_state]) # d20=2 + will 30 >= 18
|
||||||
engine._apply_fear_aura(lich_state)
|
engine._apply_fear_aura(lich_state)
|
||||||
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
|
assert all(ac.condition.name != "shaken" for ac in foe_state.active_conditions)
|
||||||
|
|
||||||
@@ -232,12 +254,27 @@ class TestLichJsonIntegrity:
|
|||||||
When: checked against the KB stat block (Bestiary "Lich":
|
When: checked against the KB stat block (Bestiary "Lich":
|
||||||
"Base Atk +5", "Melee touch +5 (1d8+5 plus paralyzing touch)",
|
"Base Atk +5", "Melee touch +5 (1d8+5 plus paralyzing touch)",
|
||||||
"AC 23, touch 14, flat-footed 21", "Fort +6, Ref +7, Will +11",
|
"AC 23, touch 14, flat-footed 21", "Fort +6, Ref +7, Will +11",
|
||||||
|
"hp 111 (11d6+55 plus 15 false life)", "**Init** +2",
|
||||||
|
"**Aura** fear (60-ft. radius, DC 18)",
|
||||||
|
"**Str** 10, **Dex** 14, **Con** —, **Int** 22, **Wis** 14, **Cha** 16",
|
||||||
and the template's "Being undead, a lich has no Constitution score")
|
and the template's "Being undead, a lich has no Constitution score")
|
||||||
Then: BAB, the natural touch attack, AC, saves and CON all match."""
|
Then: BAB, the natural touch attack, AC, saves, CON, hp, Str, Int,
|
||||||
|
initiative and the fear aura constants all match."""
|
||||||
data = json.loads(_DATA.read_text(encoding="utf-8"))
|
data = json.loads(_DATA.read_text(encoding="utf-8"))
|
||||||
assert data["bab"] == 5
|
assert data["bab"] == 5
|
||||||
assert data["ac"] == {"total": 23, "touch": 14, "flat_footed": 21}
|
assert data["ac"] == {"total": 23, "touch": 14, "flat_footed": 21}
|
||||||
assert data["saves"] == {"fort": 6, "ref": 7, "will": 11}
|
assert data["saves"] == {"fort": 6, "ref": 7, "will": 11}
|
||||||
|
# KB bestiary/lich.md gives hp 111 (11d6+55 plus 15 false life).
|
||||||
|
assert data["hp_max"] == 111
|
||||||
|
# KB Statistics: "Str 10, Dex 14, Con —, Int 22, Wis 14, Cha 16"
|
||||||
|
assert data["abilities"]["str_score"] == 10
|
||||||
|
assert data["abilities"]["int_score"] == 22
|
||||||
|
# KB bestiary/lich.md gives a +2 initiative.
|
||||||
|
assert data["initiative_mod"] == 2
|
||||||
|
# KB: "**Aura** fear (60-ft. radius, DC 18)" - the engine implements
|
||||||
|
# the aura as feature-keyed constants (only the lich carries it).
|
||||||
|
assert CombatEngine._FEAR_AURA_RANGE_FT == 60
|
||||||
|
assert CombatEngine._FEAR_AURA_DC == 18
|
||||||
touch = data["attacks"][0]
|
touch = data["attacks"][0]
|
||||||
assert touch["attack_bonus"] == 5
|
assert touch["attack_bonus"] == 5
|
||||||
# SRD lich template: "A lich has a touch attack that it can use once
|
# SRD lich template: "A lich has a touch attack that it can use once
|
||||||
|
|||||||
@@ -126,16 +126,31 @@ class TestGoldenCombats:
|
|||||||
below 0 hp on round 1 (36 -> -2) and the remaining two are lost (the
|
below 0 hp on round 1 (36 -> -2) and the remaining two are lost (the
|
||||||
engine's target-drops-mid-sequence convention), so the lich wins in 1
|
engine's target-drops-mid-sequence convention), so the lich wins in 1
|
||||||
round — outcome unchanged.
|
round — outcome unchanged.
|
||||||
|
|
||||||
|
Updated by the KB stat-block alignment (bestiary/lich.md: "hp 111",
|
||||||
|
"**Init** +2", "**Str** 10 ... **Int** 22", "Aura fear (60-ft. radius,
|
||||||
|
DC 18)") — every changed line below is SRD-correct:
|
||||||
|
- "initiative: lich d20=10+2=12": the KB's Init is +2 (the old +6 was
|
||||||
|
scenario tuning); the d20 itself is unchanged (queue position 2 = 10).
|
||||||
|
- "cast Magic Missile vs tammara DC17": spell DC = 10 + spell level 1 +
|
||||||
|
Int modifier; the KB's Int is 22 (+6, was 20/+5), so 10+1+6 = 17.
|
||||||
|
NO SAVE: Magic Missile auto-hits, so the DC display consumes no die.
|
||||||
|
- "38 damage (36->-2)" unchanged: no new die is consumed anywhere — the
|
||||||
|
fear aura does NOT fire (tammara at (0,0), lich at (10,10): 15 cells
|
||||||
|
diagonal = 75 ft, beyond even the widened 60 ft radius), and the
|
||||||
|
initiative re-roll order still puts tammara first (22 > 12).
|
||||||
|
- hp 111 never surfaces: the fight ends in round 1 with the lich
|
||||||
|
untouched.
|
||||||
"""
|
"""
|
||||||
result, _ = run_golden("tammara", "lich")
|
result, _ = run_golden("tammara", "lich")
|
||||||
assert result.winner == "monsters"
|
assert result.winner == "monsters"
|
||||||
assert result.rounds == 1
|
assert result.rounds == 1
|
||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: tammara d20=15+7=22",
|
"initiative: tammara d20=15+7=22",
|
||||||
"initiative: lich d20=10+6=16",
|
"initiative: lich d20=10+2=12",
|
||||||
("round 1 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
("round 1 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
||||||
"-> buff 2 mods (10r) (36->36)"),
|
"-> buff 2 mods (10r) (36->36)"),
|
||||||
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 38 damage (36->-2)",
|
"round 1 lich: cast Magic Missile vs tammara DC17 NO SAVE -> 38 damage (36->-2)",
|
||||||
"battle over: monsters win in 1 rounds",
|
"battle over: monsters win in 1 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user