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:
+1
-1
@@ -92,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/oni_sheet.json",
|
"source": "fiches_personnages/oni_sheet.json",
|
||||||
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme). Traits : 'Exalted of the Society' accorde +1 canalisation d'énergie/jour (channel_energy 6 -> 7, fusionné par le chargeur) ; 'Extremely Fashionable' est un no-op documenté (+1 Bluff/Diplomacy/Intimidate en habits fins, état vestimentaire non modélisé).",
|
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme). Traits : 'Exalted of the Society' accorde +1 canalisation d'énergie/jour (channel_energy 6 -> 7, fusionné par le chargeur) ; 'Extremely Fashionable' est un no-op documenté (+1 Bluff/Diplomacy/Intimidate en habits fins, état vestimentaire non modélisé) ; 'Bitter' (drawback) réduit de 1 les soins reçus d'un allié (comportement moteur).",
|
||||||
"features": [
|
"features": [
|
||||||
"Summon Good Monster",
|
"Summon Good Monster",
|
||||||
"Sacred Summons",
|
"Sacred Summons",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/tammara_sheet.json",
|
"source": "fiches_personnages/tammara_sheet.json",
|
||||||
"notes": "Trait : 'Fortified Drinker' est un no-op documenté (+2 sauvegardes vs effets mentaux après alcool, état d'ébriété non modélisé).",
|
"notes": "Traits : 'Fortified Drinker' est un no-op documenté (+2 sauvegardes vs effets mentaux après alcool, état d'ébriété non modélisé) ; 'Lonely' (drawback) applique -2 Sense Motive et -2 sauvegardes vs charme (mind_affecting remplace charme).",
|
||||||
"features": [
|
"features": [
|
||||||
"Weapon Focus (Pistol)",
|
"Weapon Focus (Pistol)",
|
||||||
"Rapid Reload (Pistol)",
|
"Rapid Reload (Pistol)",
|
||||||
|
|||||||
@@ -385,11 +385,16 @@ EXALTED_SOCIETY = AbilitySpec(
|
|||||||
resource_grants=(("channel_energy", 1),),
|
resource_grants=(("channel_energy", 1),),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bitter: +1 trait bonus on saves vs fear
|
# Bitter (Drawback): "When you receive healing from an ally's class feature,
|
||||||
|
# spell, or spell-like ability, reduce the amount of that healing by 1 hit
|
||||||
|
# point." It is a healing-application behavior, not a stat modifier: the
|
||||||
|
# engine checks for this feature by name in _channel_heal/_apply_heal_effect
|
||||||
|
# (like Power Attack is a behavior marker). The previous "+1 Will vs fear"
|
||||||
|
# was the wrong effect (and a positive bonus for a drawback).
|
||||||
BITTER = AbilitySpec(
|
BITTER = AbilitySpec(
|
||||||
name="Bitter",
|
name="Bitter",
|
||||||
category="trait",
|
category="trait",
|
||||||
effects=(StatModifier(target="will", value=1, condition="fear"),),
|
source_detail="drawback: ally healing received reduced by 1 (engine behavior)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ambitious: +1 trait bonus on one Craft or Profession skill
|
# Ambitious: +1 trait bonus on one Craft or Profession skill
|
||||||
@@ -427,11 +432,21 @@ FORTIFIED_DRINKER = AbilitySpec(
|
|||||||
# Fate's Favored: +1 luck bonus on all luck bonuses
|
# Fate's Favored: +1 luck bonus on all luck bonuses
|
||||||
FATES_FAVORED = AbilitySpec(name="Fate's Favored", category="trait")
|
FATES_FAVORED = AbilitySpec(name="Fate's Favored", category="trait")
|
||||||
|
|
||||||
# Lonely: +1 trait bonus on Will saves vs fear
|
# Lonely (Drawback): "-2 penalty on Sense Motive checks and on Perception
|
||||||
|
# checks to see through disguises, and on saving throws against charm spells
|
||||||
|
# and spell-like abilities." The engine has no "charm" save descriptor, so
|
||||||
|
# mind_affecting stands in for it (same documented convention as Inspire
|
||||||
|
# Courage). The Perception-to-see-through-disguises check is a context the
|
||||||
|
# engine never resolves, so only its Sense Motive half is modeled. The
|
||||||
|
# previous "+1 Will vs fear" was a positive bonus for a drawback - wrong.
|
||||||
LONELY = AbilitySpec(
|
LONELY = AbilitySpec(
|
||||||
name="Lonely",
|
name="Lonely",
|
||||||
category="trait",
|
category="trait",
|
||||||
effects=(StatModifier(target="will", value=1, condition="fear"),),
|
source_detail="drawback: -2 Sense Motive, -2 saves vs charm (mind_affecting stands in)",
|
||||||
|
effects=(
|
||||||
|
StatModifier(target="sense_motive", value=-2),
|
||||||
|
StatModifier(target="will", value=-2, condition="mind_affecting"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Larger Than Life: +1 trait bonus on Intimidate
|
# Larger Than Life: +1 trait bonus on Intimidate
|
||||||
|
|||||||
@@ -5459,6 +5459,7 @@ class CombatEngine:
|
|||||||
def _channel_heal(
|
def _channel_heal(
|
||||||
self, state: CombatantState, affected_state: CombatantState, dmg: int
|
self, state: CombatantState, affected_state: CombatantState, dmg: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
dmg = self._bitter_reduced(state, affected_state, dmg)
|
||||||
old_hp = affected_state.hp
|
old_hp = affected_state.hp
|
||||||
affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max)
|
affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max)
|
||||||
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
||||||
@@ -6526,6 +6527,7 @@ class CombatEngine:
|
|||||||
return self._apply_heal_effect(
|
return self._apply_heal_effect(
|
||||||
effect, target, caster_level=caster.combatant.level,
|
effect, target, caster_level=caster.combatant.level,
|
||||||
empower=empower, maximize=maximize, saved=saved,
|
empower=empower, maximize=maximize, saved=saved,
|
||||||
|
healer=caster,
|
||||||
)
|
)
|
||||||
if isinstance(effect, PenaltyEffect):
|
if isinstance(effect, PenaltyEffect):
|
||||||
return self._apply_penalty_effect(
|
return self._apply_penalty_effect(
|
||||||
@@ -6719,6 +6721,23 @@ class CombatEngine:
|
|||||||
target.bleed_damage = max(target.bleed_damage, 1)
|
target.bleed_damage = max(target.bleed_damage, 1)
|
||||||
return f"{condition_name} ({duration_rounds}r)"
|
return f"{condition_name} ({duration_rounds}r)"
|
||||||
|
|
||||||
|
def _bitter_reduced(
|
||||||
|
self, healer: CombatantState, target: CombatantState, amount: int
|
||||||
|
) -> int:
|
||||||
|
"""PF1e (Drawback "Bitter"): "When you receive healing from an ally's
|
||||||
|
class feature, spell, or spell-like ability, reduce the amount of that
|
||||||
|
healing by 1 hit point." Applies only to ally-sourced healing (an
|
||||||
|
ally is another creature on the same side); self-healing is untouched.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
amount > 0
|
||||||
|
and healer is not target
|
||||||
|
and healer.side == target.side
|
||||||
|
and self._has_feat(target, "Bitter")
|
||||||
|
):
|
||||||
|
return amount - 1
|
||||||
|
return amount
|
||||||
|
|
||||||
def _apply_heal_effect(
|
def _apply_heal_effect(
|
||||||
self,
|
self,
|
||||||
effect: HealEffect,
|
effect: HealEffect,
|
||||||
@@ -6728,6 +6747,7 @@ class CombatEngine:
|
|||||||
empower: bool,
|
empower: bool,
|
||||||
maximize: bool,
|
maximize: bool,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
healer: CombatantState | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
formula = effect.effective_formula(caster_level)
|
formula = effect.effective_formula(caster_level)
|
||||||
heal = formula.roll(self._rng)
|
heal = formula.roll(self._rng)
|
||||||
@@ -6742,10 +6762,13 @@ class CombatEngine:
|
|||||||
# resistance, and can attempt a Will save to take half damage."
|
# resistance, and can attempt a Will save to take half damage."
|
||||||
# SR is resolved upstream (_spell_delivery_blocked); the rolled
|
# SR is resolved upstream (_spell_delivery_blocked); the rolled
|
||||||
# save halves here. Cure damage is spell energy: never reduced by
|
# save halves here. Cure damage is spell energy: never reduced by
|
||||||
# DR, and it does not stop bleeding or stabilize.
|
# DR, and it does not stop bleeding or stabilize. Bitter reduces
|
||||||
|
# healing, not this damage.
|
||||||
dmg = heal // 2 if saved else heal
|
dmg = heal // 2 if saved else heal
|
||||||
target.hp -= dmg
|
target.hp -= dmg
|
||||||
return f"{dmg} damage (undead)"
|
return f"{dmg} damage (undead)"
|
||||||
|
if healer is not None:
|
||||||
|
heal = self._bitter_reduced(healer, target, heal)
|
||||||
old_hp = target.hp
|
old_hp = target.hp
|
||||||
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
||||||
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ Supported condition families (plan B1):
|
|||||||
real Axe to Grind trait is a documented no-op, see test_feats.py).
|
real Axe to Grind trait is a documented no-op, see test_feats.py).
|
||||||
- save descriptors (fear, cold, electricity, fey, divination, poison,
|
- save descriptors (fear, cold, electricity, fey, divination, poison,
|
||||||
disease, mind_affecting) — the saving throw's context, threaded from the
|
disease, mind_affecting) — the saving throw's context, threaded from the
|
||||||
fear aura and from spell effects/schools (Bitter, Lonely, Breeze-Kissed,
|
fear aura and from spell effects/schools (Breeze-Kissed, Fey-touched,
|
||||||
Fey-touched, Spell Vulnerability, Born Damned, Pingre, Half-Forgotten
|
Spell Vulnerability, Pingre, Half-Forgotten Secrets, Lonely's -2 vs
|
||||||
Secrets).
|
mind_affecting standing in for charm).
|
||||||
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
|
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
|
||||||
alcohol) and unknown names always evaluate to False.
|
alcohol) and unknown names always evaluate to False.
|
||||||
"""
|
"""
|
||||||
@@ -28,8 +28,6 @@ from typing import Literal
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.abilities import (
|
from pf1e_simulator.abilities import (
|
||||||
BITTER,
|
|
||||||
BORN_DAMNED,
|
|
||||||
BREEZE_KISSED,
|
BREEZE_KISSED,
|
||||||
FEY_TOUCHED,
|
FEY_TOUCHED,
|
||||||
FORTIFIED_DRINKER,
|
FORTIFIED_DRINKER,
|
||||||
@@ -227,8 +225,6 @@ def test_axe_condition_inactive_without_axe() -> None:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("feat", "save_type", "dc", "descriptors"),
|
("feat", "save_type", "dc", "descriptors"),
|
||||||
[
|
[
|
||||||
(BITTER, "will", 19, frozenset({"fear"})),
|
|
||||||
(LONELY, "will", 19, frozenset({"fear"})),
|
|
||||||
(BREEZE_KISSED, "fort", 20, frozenset({"cold"})),
|
(BREEZE_KISSED, "fort", 20, frozenset({"cold"})),
|
||||||
(BREEZE_KISSED, "ref", 20, frozenset({"electricity"})),
|
(BREEZE_KISSED, "ref", 20, frozenset({"electricity"})),
|
||||||
(FEY_TOUCHED, "will", 19, frozenset({"fey"})),
|
(FEY_TOUCHED, "will", 19, frozenset({"fey"})),
|
||||||
@@ -249,13 +245,25 @@ def test_conditioned_save_bonus_applies_when_descriptor_present(
|
|||||||
assert result.success is True
|
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(
|
@pytest.mark.parametrize(
|
||||||
("feat", "save_type", "dc", "descriptors"),
|
("feat", "save_type", "dc", "descriptors"),
|
||||||
[
|
[
|
||||||
(BITTER, "will", 19, frozenset({"cold"})),
|
|
||||||
(BREEZE_KISSED, "fort", 19, frozenset({"fear"})),
|
(BREEZE_KISSED, "fort", 19, frozenset({"fear"})),
|
||||||
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
|
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
|
||||||
(BORN_DAMNED, "fort", 19, frozenset({"cold"})),
|
|
||||||
(PINGRE, "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:
|
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(
|
result = _resolve_save(
|
||||||
FORTIFIED_DRINKER, "fort", 19, save_descriptors=frozenset({"poison"}), roll=18,
|
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:
|
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()
|
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))
|
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([21], [lich_state, foe_state]) # 21 + 0 + 1 = 22 >= DC 22
|
||||||
|
|||||||
@@ -13,16 +13,20 @@ from pathlib import Path
|
|||||||
|
|
||||||
from pf1e_simulator.abilities import (
|
from pf1e_simulator.abilities import (
|
||||||
AXE_TO_GRIND,
|
AXE_TO_GRIND,
|
||||||
|
BITTER,
|
||||||
BORN_DAMNED,
|
BORN_DAMNED,
|
||||||
COMBAT_REFLEXES,
|
COMBAT_REFLEXES,
|
||||||
EXALTED_SOCIETY,
|
EXALTED_SOCIETY,
|
||||||
EXTREMELY_FASHIONABLE,
|
EXTREMELY_FASHIONABLE,
|
||||||
FORTIFIED_DRINKER,
|
FORTIFIED_DRINKER,
|
||||||
FRIENDLY_FACE,
|
FRIENDLY_FACE,
|
||||||
|
LONELY,
|
||||||
MOUNTED_COMBAT,
|
MOUNTED_COMBAT,
|
||||||
POINT_BLANK_SHOT,
|
POINT_BLANK_SHOT,
|
||||||
|
AbilitySpec,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.loaders.combatant import (
|
from pf1e_simulator.loaders.combatant import (
|
||||||
_KNOWN_FEATS,
|
_KNOWN_FEATS,
|
||||||
@@ -31,7 +35,16 @@ from pf1e_simulator.loaders.combatant import (
|
|||||||
load_combatant,
|
load_combatant,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.map import MapSpec, TerrainType
|
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.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import HealEffect
|
||||||
|
|
||||||
PC_FILES = [
|
PC_FILES = [
|
||||||
"data/pcs/esha.json",
|
"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)."""
|
inert no-op (old flat +4 Diplomacy was the wrong value and unconditional)."""
|
||||||
assert EXTREMELY_FASHIONABLE.effects == ()
|
assert EXTREMELY_FASHIONABLE.effects == ()
|
||||||
assert "not modeled" in EXTREMELY_FASHIONABLE.source_detail
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user