diff --git a/README.md b/README.md index 23003ed..580f988 100644 --- a/README.md +++ b/README.md @@ -1646,14 +1646,20 @@ Règles modélisées : moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient lieu (surcouverture : les effets de compulsion comptent aussi)) — couvertes par `tests/test_class_abilities.py`. -- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie - positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou +- Le Liche (`data/monsters/lich.json`) : bloc de stats aligné sur la fiche KB + `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, `paralysé` 1d4+1 rounds), RD 15/contondant **et** magie (conformance P2 : 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 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 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 diff --git a/data/monsters/lich.json b/data/monsters/lich.json index 5ba2f39..1284b37 100644 --- a/data/monsters/lich.json +++ b/data/monsters/lich.json @@ -4,21 +4,21 @@ "size": "Medium", "creature_type": "undead", "abilities": { - "str_score": 12, + "str_score": 10, "dex_score": 14, "con_score": 10, - "int_score": 20, + "int_score": 22, "wis_score": 14, "cha_score": 16 }, - "hp_max": 100, + "hp_max": 111, "ac": { "total": 23, "touch": 14, "flat_footed": 21 }, "bab": 5, - "initiative_mod": 6, + "initiative_mod": 2, "speed_land_ft": 30, "saves": { "fort": 6, @@ -59,6 +59,7 @@ "bypass": ["bludgeoning", "magic"] }, "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": [ "Fear Aura", "Paralyzing Touch", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4408750..e9b9394 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2802,12 +2802,13 @@ class CombatEngine: return False return weapon.kind == "touch" or weapon.name == "Paralyzing Touch" - _FEAR_AURA_DC = 22 - _FEAR_AURA_RANGE_FT = 30 + # KB bestiary/lich.md: "Aura fear (60-ft. radius, DC 18)". + _FEAR_AURA_DC = 18 + _FEAR_AURA_RANGE_FT = 60 _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. + """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 PF1e "on range entry" trigger — the lich acts first each round, so this diff --git a/tests/test_conditional_modifiers.py b/tests/test_conditional_modifiers.py index 8b22c9b..1891ce2 100644 --- a/tests/test_conditional_modifiers.py +++ b/tests/test_conditional_modifiers.py @@ -378,7 +378,7 @@ def test_fear_aura_save_carries_fear_descriptor() -> None: foe = make_combatant("foe", will=0, features=(_FEAR_SAVE_PROBE,)) lich_state = make_state(lich, "monsters", (1, 1)) 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) 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) lich_state = make_state(lich, "monsters", (1, 1)) 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) assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions) diff --git a/tests/test_lich.py b/tests/test_lich.py index 2d79a55..94fe3b3 100644 --- a/tests/test_lich.py +++ b/tests/test_lich.py @@ -150,8 +150,9 @@ class TestParalyzingTouch: class TestFearAura: - """Given: an enemy within 30 ft at the start of the Lich's turn - When: it fails its Will save (DC 22) + """Given: an enemy within 60 ft at the start of the Lich's turn + 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.""" def test_failed_save_applies_shaken(self) -> None: @@ -159,18 +160,39 @@ class TestFearAura: 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 = _make_engine([2], [lich_state, foe_state]) # d20=2 -> fails Will DC 18 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_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: 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 = _make_engine([2], [lich_state, foe_state]) # d20=2 + will 30 >= 18 engine._apply_fear_aura(lich_state) 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": "Base Atk +5", "Melee touch +5 (1d8+5 plus paralyzing touch)", "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") - 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")) assert data["bab"] == 5 assert data["ac"] == {"total": 23, "touch": 14, "flat_footed": 21} 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] assert touch["attack_bonus"] == 5 # SRD lich template: "A lich has a touch attack that it can use once diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index 5743c9c..4e18a0b 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -126,16 +126,31 @@ class TestGoldenCombats: 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 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") assert result.winner == "monsters" assert result.rounds == 1 assert result.transcript == ( "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 " "-> 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", )