fix(rules): Lonely and Bitter drawbacks replace bogus +1 Will bonuses (SRD: Lonely -2 Sense Motive and -2 saves vs charm with mind_affecting standing in; Bitter reduces ally-sourced healing by 1 via engine hook)

This commit is contained in:
2026-09-19 10:50:50 +02:00
parent a5ebbecad8
commit b74baf1aa9
6 changed files with 177 additions and 19 deletions
+27 -12
View File
@@ -13,9 +13,9 @@ Supported condition families (plan B1):
real Axe to Grind trait is a documented no-op, see test_feats.py).
- save descriptors (fear, cold, electricity, fey, divination, poison,
disease, mind_affecting) — the saving throw's context, threaded from the
fear aura and from spell effects/schools (Bitter, Lonely, Breeze-Kissed,
Fey-touched, Spell Vulnerability, Born Damned, Pingre, Half-Forgotten
Secrets).
fear aura and from spell effects/schools (Breeze-Kissed, Fey-touched,
Spell Vulnerability, Pingre, Half-Forgotten Secrets, Lonely's -2 vs
mind_affecting standing in for charm).
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
alcohol) and unknown names always evaluate to False.
"""
@@ -28,8 +28,6 @@ from typing import Literal
import pytest
from pf1e_simulator.abilities import (
BITTER,
BORN_DAMNED,
BREEZE_KISSED,
FEY_TOUCHED,
FORTIFIED_DRINKER,
@@ -227,8 +225,6 @@ def test_axe_condition_inactive_without_axe() -> None:
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(BITTER, "will", 19, frozenset({"fear"})),
(LONELY, "will", 19, frozenset({"fear"})),
(BREEZE_KISSED, "fort", 20, frozenset({"cold"})),
(BREEZE_KISSED, "ref", 20, frozenset({"electricity"})),
(FEY_TOUCHED, "will", 19, frozenset({"fey"})),
@@ -249,13 +245,25 @@ def test_conditioned_save_bonus_applies_when_descriptor_present(
assert result.success is True
def test_lonely_is_a_negative_mind_affecting_penalty() -> None:
"""Lonely (drawback): -2 Will vs mind_affecting (charm stands in), never a bonus."""
penalized = _resolve_save(
LONELY, "will", 17, save_descriptors=frozenset({"mind_affecting"}), roll=18,
)
assert penalized.total == 16 # 18 - 2
assert penalized.success is False
unaffected = _resolve_save(
LONELY, "will", 17, save_descriptors=frozenset({"fear"}), roll=18,
)
assert unaffected.total == 18
assert unaffected.success is True
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(BITTER, "will", 19, frozenset({"cold"})),
(BREEZE_KISSED, "fort", 19, frozenset({"fear"})),
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
(BORN_DAMNED, "fort", 19, frozenset({"cold"})),
(PINGRE, "fort", 19, frozenset({"cold"})),
],
)
@@ -303,7 +311,7 @@ def test_mounted_condition_unsupported_stays_excluded() -> None:
def test_alcohol_condition_unsupported_stays_excluded() -> None:
"""Fortified Drinker: 'alcohol' context never exists -> excluded."""
"""Fortified Drinker is an inert no-op: no bonus on any save context."""
result = _resolve_save(
FORTIFIED_DRINKER, "fort", 19, save_descriptors=frozenset({"poison"}), roll=18,
)
@@ -336,10 +344,17 @@ def _make_fear_aura_lich() -> Combatant:
)
_FEAR_SAVE_PROBE = AbilitySpec(
name="Fear Save Probe",
category="feat",
effects=(StatModifier(target="will", value=1, condition="fear"),),
)
def test_fear_aura_save_carries_fear_descriptor() -> None:
"""The lich's fear aura save carries 'fear': Lonely's +1 flips the save."""
"""The lich's fear aura save carries 'fear': a +1 fear bonus flips the save."""
lich = _make_fear_aura_lich()
foe = make_combatant("foe", will=0, features=(LONELY,))
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
+105
View File
@@ -13,16 +13,20 @@ from pathlib import Path
from pf1e_simulator.abilities import (
AXE_TO_GRIND,
BITTER,
BORN_DAMNED,
COMBAT_REFLEXES,
EXALTED_SOCIETY,
EXTREMELY_FASHIONABLE,
FORTIFIED_DRINKER,
FRIENDLY_FACE,
LONELY,
MOUNTED_COMBAT,
POINT_BLANK_SHOT,
AbilitySpec,
)
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import (
_KNOWN_FEATS,
@@ -31,7 +35,16 @@ from pf1e_simulator.loaders.combatant import (
load_combatant,
)
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import HealEffect
PC_FILES = [
"data/pcs/esha.json",
@@ -290,3 +303,95 @@ def test_extremely_fashionable_is_inert_documented_noop() -> None:
inert no-op (old flat +4 Diplomacy was the wrong value and unconditional)."""
assert EXTREMELY_FASHIONABLE.effects == ()
assert "not modeled" in EXTREMELY_FASHIONABLE.source_detail
def _make_battle_combatant(
cid: str, *, features: tuple[AbilitySpec, ...] = (), hp_max: int = 30
) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=12, dex_score=12, con_score=12,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp_max,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[
AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
],
features=list(features),
)
def _bitter_pair() -> tuple[CombatEngine, CombatantState, CombatantState]:
"""Two same-side states: a plain healer and a target carrying Bitter."""
healer_c = _make_battle_combatant("healer")
bitter_c = _make_battle_combatant("bitter", features=(BITTER,))
healer = CombatantState(combatant=healer_c, side="players", pos=(0, 0), hp=30)
target = CombatantState(combatant=bitter_c, side="players", pos=(1, 0), hp=10)
legend = {".": TerrainType(type="floor", move_cost=1)}
grid = Grid.from_spec(
MapSpec(name="t", terrain=tuple([".." for _ in range(4)]), legend=legend)
)
engine = CombatEngine(ScriptedRng([]), grid, [healer, target])
return engine, healer, target
def test_lonely_is_a_negative_drawback_not_a_bonus() -> None:
"""SRD (drawback): -2 Sense Motive and -2 saves vs charm (mind_affecting
stands in). No positive modifier may remain."""
assert LONELY.effects
assert all(e.value < 0 for e in LONELY.effects), LONELY.effects
assert any(e.target == "sense_motive" and e.value == -2 for e in LONELY.effects)
will = [e for e in LONELY.effects if e.target == "will"]
assert len(will) == 1
assert will[0].value == -2
assert will[0].condition == "mind_affecting"
def test_bitter_is_a_behavior_marker_without_stat_effects() -> None:
"""The drawback is applied by the engine at healing sites, not as a stat
modifier - and the old positive '+1 Will vs fear' is gone."""
assert BITTER.effects == ()
assert "engine behavior" in BITTER.source_detail
def test_bitter_reduces_ally_channel_healing_by_one() -> None:
"""SRD (drawback): ally class-feature healing received is reduced by 1."""
engine, healer, target = _bitter_pair()
engine._channel_heal(healer, target, 6)
assert target.hp == 15 # 10 + 6 - 1
def test_bitter_reduces_ally_spell_healing_by_one() -> None:
"""Ally spell healing is reduced by 1 as well (rolled 8 -> heals 7)."""
engine, healer, target = _bitter_pair()
engine._rng = ScriptedRng([8])
effect = HealEffect(formula=parse_dice("1d8"))
engine._apply_heal_effect(
effect, target, caster_level=1, empower=False, maximize=False,
saved=False, healer=healer,
)
assert target.hp == 17 # 10 + 8 - 1
def test_bitter_leaves_self_healing_untouched() -> None:
"""The drawback is about accepting help from allies, not self-healing."""
engine, healer, _ = _bitter_pair()
healer.combatant.features.append(BITTER)
healer.hp = 10
engine._channel_heal(healer, healer, 6)
assert healer.hp == 16 # no reduction