From 726256895938c05fb9ba579792b479a865a3b60f Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 12:37:32 +0200 Subject: [PATCH] fix(rules): Esha sylph traits and invented names to documented traced no-ops (Whispering Wind/Like the Wind/Breeze-Kissed are real sylph racial traits whose SRD effects the engine cannot resolve - the coded bonuses were fabricated; Fey-touched and Pingre are not PF1e features and move to _NOOP_FEATS; descriptor gating stays covered via local probe specs) --- src/pf1e_simulator/abilities.py | 50 ++++++++++--------------- src/pf1e_simulator/loaders/combatant.py | 13 ++++--- tests/test_conditional_modifiers.py | 45 ++++++++++++++++------ 3 files changed, 59 insertions(+), 49 deletions(-) diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index 3849dd5..d0aec55 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -198,28 +198,35 @@ VAGABOND_CHILD = AbilitySpec( effects=(StatModifier(target="sleight_of_hand", value=1),), ) -# Whispering Wind: +1 trait bonus on Fly checks +# Whispering Wind (Sylph alternate racial trait, ARG): "+4 racial bonus on +# Stealth checks" (replaces the sylph spell-like ability). Esha's sheet +# declares race Sylph, so the pick is legal; the engine resolves no Stealth +# checks -> documented traced no-op. WHISPERING_WIND = AbilitySpec( name="Whispering Wind", - category="trait", - effects=(StatModifier(target="fly", value=1),), + category="racial", + source_detail="racial sylph (not modeled: +4 racial Stealth, no stealth checks)", ) -# Breeze-Kissed: +2 trait bonus on saves vs. cold and electricity +# Breeze-Kissed (Sylph alternate racial trait, ARG): "+2 racial bonus to AC +# against nonmagical ranged attacks" plus a 1/day gust bull rush or trip +# (replaces air affinity). No magical/nonmagical AC filter exists (AC is a +# final sheet value) and the 1/day maneuver is not selectable -> documented +# traced no-op. BREEZE_KISSED = AbilitySpec( name="Breeze-Kissed", - category="trait", - effects=( - StatModifier(target="fort", value=2, condition="cold"), - StatModifier(target="ref", value=2, condition="electricity"), - ), + category="racial", + source_detail="racial sylph (not modeled: +2 AC vs nonmagical ranged, 1/day gust)", ) -# Like the Wind: +10 ft movement speed +# Like the Wind (Sylph alternate racial trait, ARG): "+5 foot bonus to base +# speed" (replaces energy resistance). The engine never resolves "speed_ft" +# stat modifiers (movement reads the final JSON speed), and Esha's sheet +# carries the final land speed 25 -> documented traced no-op. LIKE_THE_WIND = AbilitySpec( name="Like the Wind", - category="trait", - effects=(StatModifier(target="speed_ft", value=10),), + category="racial", + source_detail="racial sylph (not modeled: +5 ft base speed, speed modifiers unresolved)", ) # Friendly Face (feat, Animal Archive): "Other animals of roughly the same @@ -232,15 +239,6 @@ FRIENDLY_FACE = AbilitySpec( source_detail="feat (not modeled: animal initial attitude +2 steps)", ) -# Fey-touched: +1 trait bonus on saves vs. fey effects -FEY_TOUCHED = AbilitySpec( - name="Fey-touched", - category="trait", - effects=( - StatModifier(target="will", value=1, condition="fey"), - ), -) - # Combat Reflexes: additional AoO per round equal to Dex mod COMBAT_REFLEXES = AbilitySpec( name="Combat Reflexes", @@ -343,16 +341,6 @@ HALF_FORGOTTEN_SECRETS = AbilitySpec( ), ) -# Pingre: +1 trait bonus on Appraise and saves vs poison -PINGRE = AbilitySpec( - name="Pingre", - category="trait", - effects=( - StatModifier(target="appraise", value=1), - StatModifier(target="fort", value=1, condition="poison"), - ), -) - # Summon Good Monster: expanded summon list SUMMON_GOOD_MONSTER = AbilitySpec(name="Summon Good Monster", category="feat") diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 46e8a77..4961c7e 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -46,7 +46,6 @@ from pf1e_simulator.abilities import ( EXTRA_HEX, EXTREMELY_FASHIONABLE, FATES_FAVORED, - FEY_TOUCHED, FOCUSED_STUDY, FORTIFIED_DRINKER, FRIENDLY_FACE, @@ -62,7 +61,6 @@ from pf1e_simulator.abilities import ( LONELY, MAW_OR_CLAW, MOUNTED_COMBAT, - PINGRE, POINT_BLANK_SHOT, POWER_ATTACK, PRECISE_SHOT, @@ -110,7 +108,6 @@ _KNOWN_FEATS: dict[str, AbilitySpec] = { "breeze-kissed": BREEZE_KISSED, "like the wind": LIKE_THE_WIND, "friendly face": FRIENDLY_FACE, - "fey-touched": FEY_TOUCHED, "combat reflexes": COMBAT_REFLEXES, "armor proficiency (light)": ARMOR_PROFICIENCY_LIGHT, "armor proficiency (medium)": ARMOR_PROFICIENCY_MEDIUM, @@ -128,7 +125,6 @@ _KNOWN_FEATS: dict[str, AbilitySpec] = { "extra hex": EXTRA_HEX, "eschew materials": ESCHEW_MATERIALS, "half-forgotten secrets (dhampir; ajibachana)": HALF_FORGOTTEN_SECRETS, - "pingre": PINGRE, "summon good monster": SUMMON_GOOD_MONSTER, "sacred summons": SACRED_SUMS, "selective channeling": SELECTIVE_CHANNELING, @@ -147,13 +143,18 @@ _KNOWN_FEATS: dict[str, AbilitySpec] = { "focused study (trait racial humain \u2014 remplace le bonus feat)": FOCUSED_STUDY, } -# Feat names with no combat-mechanical definition (flavor / race descriptors / jokes). -# These are resolved to traced no-ops so the loader never silently drops a name. +# Feat names with no combat-mechanical definition (flavor / race descriptors / +# jokes). These are resolved to traced no-ops so the loader never silently +# drops a name. "Fey-touched" (Harvie's sheet) and "Pingre" (Nairda's sheet, +# French for "miser") are not PF1e features; the SRD has no entry under those +# names, so no effect can be attributed to them. _NOOP_FEATS: frozenset[str] = frozenset({ "6. vert - yes mistress", "animal (race)", "drake (race)", + "fey-touched", "orange - lazy ass", + "pingre", "plot armor", }) diff --git a/tests/test_conditional_modifiers.py b/tests/test_conditional_modifiers.py index 2ed75e1..8b22c9b 100644 --- a/tests/test_conditional_modifiers.py +++ b/tests/test_conditional_modifiers.py @@ -13,9 +13,10 @@ 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 (Breeze-Kissed, Fey-touched, - Spell Vulnerability, Pingre, Half-Forgotten Secrets, Lonely's -2 vs - mind_affecting standing in for charm). + fear aura and from spell effects/schools (real feats: Spell Vulnerability, + Half-Forgotten Secrets, Lonely's -2 vs mind_affecting standing in for + charm; the remaining descriptors are exercised through the local probe + specs below). - Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse, alcohol) and unknown names always evaluate to False. """ @@ -28,13 +29,10 @@ from typing import Literal import pytest from pf1e_simulator.abilities import ( - BREEZE_KISSED, - FEY_TOUCHED, FORTIFIED_DRINKER, HALF_FORGOTTEN_SECRETS, LONELY, MOUNTED_COMBAT, - PINGRE, POINT_BLANK_SHOT, SPELL_VULNERABILITY_DIVINATION, AbilitySpec, @@ -221,16 +219,39 @@ def test_axe_condition_inactive_without_axe() -> None: # ── Save descriptor conditions ──────────────────────────────────────────────── +# Probe specs exercising the descriptor gating (the loader's real feats for +# these descriptors are documented no-ops, see test_feats.py). +_COLD_FORT_PROBE = AbilitySpec( + name="Cold Fort Probe", + category="feat", + effects=(StatModifier(target="fort", value=2, condition="cold"),), +) +_ELEC_REF_PROBE = AbilitySpec( + name="Electricity Ref Probe", + category="feat", + effects=(StatModifier(target="ref", value=2, condition="electricity"),), +) +_FEY_WILL_PROBE = AbilitySpec( + name="Fey Will Probe", + category="feat", + effects=(StatModifier(target="will", value=1, condition="fey"),), +) +_POISON_FORT_PROBE = AbilitySpec( + name="Poison Fort Probe", + category="feat", + effects=(StatModifier(target="fort", value=1, condition="poison"),), +) + @pytest.mark.parametrize( ("feat", "save_type", "dc", "descriptors"), [ - (BREEZE_KISSED, "fort", 20, frozenset({"cold"})), - (BREEZE_KISSED, "ref", 20, frozenset({"electricity"})), - (FEY_TOUCHED, "will", 19, frozenset({"fey"})), + (_COLD_FORT_PROBE, "fort", 20, frozenset({"cold"})), + (_ELEC_REF_PROBE, "ref", 20, frozenset({"electricity"})), + (_FEY_WILL_PROBE, "will", 19, frozenset({"fey"})), (HALF_FORGOTTEN_SECRETS, "fort", 20, frozenset({"disease"})), (HALF_FORGOTTEN_SECRETS, "will", 20, frozenset({"mind_affecting"})), - (PINGRE, "fort", 19, frozenset({"poison"})), + (_POISON_FORT_PROBE, "fort", 19, frozenset({"poison"})), ], ) def test_conditioned_save_bonus_applies_when_descriptor_present( @@ -262,9 +283,9 @@ def test_lonely_is_a_negative_mind_affecting_penalty() -> None: @pytest.mark.parametrize( ("feat", "save_type", "dc", "descriptors"), [ - (BREEZE_KISSED, "fort", 19, frozenset({"fear"})), + (_COLD_FORT_PROBE, "fort", 19, frozenset({"fear"})), (HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})), - (PINGRE, "fort", 19, frozenset({"cold"})), + (_POISON_FORT_PROBE, "fort", 19, frozenset({"cold"})), ], ) def test_conditioned_save_bonus_excluded_without_matching_descriptor(