diff --git a/data/pcs/oni.json b/data/pcs/oni.json index 454d448..647cb5d 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -92,7 +92,7 @@ } ], "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": [ "Summon Good Monster", "Sacred Summons", diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 320c0ab..8ff6f2a 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -130,7 +130,7 @@ } ], "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": [ "Weapon Focus (Pistol)", "Rapid Reload (Pistol)", diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index ef46973..6b2acdc 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -385,11 +385,16 @@ EXALTED_SOCIETY = AbilitySpec( 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( name="Bitter", 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 @@ -427,11 +432,21 @@ FORTIFIED_DRINKER = AbilitySpec( # Fate's Favored: +1 luck bonus on all luck bonuses 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( name="Lonely", 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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 7e0a5e4..be72fbc 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -5459,6 +5459,7 @@ class CombatEngine: def _channel_heal( self, state: CombatantState, affected_state: CombatantState, dmg: int ) -> None: + dmg = self._bitter_reduced(state, affected_state, dmg) old_hp = affected_state.hp affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) # PF1e (CRB, Stable Characters and Recovery): "If any sort of healing @@ -6526,6 +6527,7 @@ class CombatEngine: return self._apply_heal_effect( effect, target, caster_level=caster.combatant.level, empower=empower, maximize=maximize, saved=saved, + healer=caster, ) if isinstance(effect, PenaltyEffect): return self._apply_penalty_effect( @@ -6719,6 +6721,23 @@ class CombatEngine: target.bleed_damage = max(target.bleed_damage, 1) 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( self, effect: HealEffect, @@ -6728,6 +6747,7 @@ class CombatEngine: empower: bool, maximize: bool, saved: bool, + healer: CombatantState | None = None, ) -> str: formula = effect.effective_formula(caster_level) heal = formula.roll(self._rng) @@ -6742,10 +6762,13 @@ class CombatEngine: # resistance, and can attempt a Will save to take half damage." # SR is resolved upstream (_spell_delivery_blocked); the rolled # 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 target.hp -= dmg return f"{dmg} damage (undead)" + if healer is not None: + heal = self._bitter_reduced(healer, target, heal) old_hp = target.hp target.hp = min(target.hp + heal, target.combatant.hp_max) # PF1e (CRB, Stable Characters and Recovery): "If any sort of healing diff --git a/tests/test_conditional_modifiers.py b/tests/test_conditional_modifiers.py index d697b3b..2ed75e1 100644 --- a/tests/test_conditional_modifiers.py +++ b/tests/test_conditional_modifiers.py @@ -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 diff --git a/tests/test_feats.py b/tests/test_feats.py index b4938a7..dad7ac0 100644 --- a/tests/test_feats.py +++ b/tests/test_feats.py @@ -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