From d0db20861063489d77d7fab6fa208d47bb08c2a8 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 10:37:23 +0200 Subject: [PATCH] fix(rules): Exalted of the Society grants +1 channel energy/day via resource_grants; Fortified Drinker becomes documented no-op (SRD: +2 saves vs mind-affecting after drinking, alcohol state not modeled) --- data/pcs/oni.json | 2 +- data/pcs/tammara.json | 1 + src/pf1e_simulator/abilities.py | 30 +++++++++++++++++------- src/pf1e_simulator/loaders/combatant.py | 31 ++++++++++++++++++++++++- tests/test_feats.py | 26 +++++++++++++++++++++ 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/data/pcs/oni.json b/data/pcs/oni.json index e6b18c6..b6d64cf 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).", + "notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme). Trait : 'Exalted of the Society' accorde +1 canalisation d'énergie/jour (channel_energy 6 -> 7, fusionné par le chargeur).", "features": [ "Summon Good Monster", "Sacred Summons", diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 2da8855..320c0ab 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -130,6 +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é).", "features": [ "Weapon Focus (Pistol)", "Rapid Reload (Pistol)", diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index 949be61..d69500e 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -28,12 +28,18 @@ class AbilitySpec: ``effects`` are the StatModifiers this ability grants. For passive feats, these are always-on modifiers. For conditional feats, ``condition`` on each StatModifier gates applicability (evaluated by the engine). + + ``resource_grants`` are per-day resource-pool bumps (e.g. channel energy + uses). The engine stores resource pools on ``Combatant.resources`` (not + as stat modifiers), so the loader (``loaders/combatant.py``) merges these + grants into the combatant's ``resources`` dict at load time. """ name: str category: Category source_detail: str = "" effects: tuple[StatModifier, ...] = () + resource_grants: tuple[tuple[str, int], ...] = () # ── Passive combat feats ───────────────────────────────────────────────────── @@ -351,14 +357,16 @@ EXTREMELY_FASHIONABLE = AbilitySpec( effects=(StatModifier(target="diplomacy", value=4),), ) -# Exalted of the Society: +1 on Knowledge (local) and (nobility) +# Exalted of the Society (Basic Faith trait, Faction Guide; requires Cleric + +# Pathfinder Society): "You may channel energy one additional time per day." +# Channel uses live in the resource pool (Combatant.resources), so the trait +# grants +1 channel_energy there via resource_grants (merged by the loader) +# instead of a stat modifier. The previous +1 Knowledge (local)/(nobility) +# bonus was the wrong effect (the SRD trait grants no skill bonus). EXALTED_SOCIETY = AbilitySpec( name="Exalted of the Society (Cleric, Pathfinder Society)", category="trait", - effects=( - StatModifier(target="knowledge_local", value=1), - StatModifier(target="knowledge_nobility", value=1), - ), + resource_grants=(("channel_energy", 1),), ) # Bitter: +1 trait bonus on saves vs fear @@ -387,13 +395,17 @@ SKILL_FOCUS_PERCEPTION = AbilitySpec( # Dazzling Display: intimidate as standard action DAZZLING_DISPLAY = AbilitySpec(name="Dazzling Display", category="feat") -# Fortified Drinker: +2 on saves vs poison when drinking alcohol +# Fortified Drinker (Religion trait, APG; requires Cayden Cailean): +# "Whenever you imbibe any alcoholic beverage, you gain a +2 trait bonus on +# saves against mind-affecting effects for 1 hour." The +1-hour-after-drinking +# trigger needs an alcohol-consumption state the engine does not model, and +# the previous "+2 Fort vs poison, condition='alcohol'" was doubly wrong +# (wrong save, wrong descriptor) and dead ("alcohol" never evaluates). +# -> documented traced no-op. FORTIFIED_DRINKER = AbilitySpec( name="Fortified Drinker (Cayden Cailean)", category="trait", - effects=( - StatModifier(target="fort", value=2, condition="alcohol"), - ), + source_detail="trait (not modeled: +2 saves vs mind-affecting after drinking)", ) # Fate's Favored: +1 luck bonus on all luck bonuses diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 3eb7d2d..46e8a77 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -195,6 +195,31 @@ def _resolve_features(raw_features: list[Json], level: int) -> list[AbilitySpec] ] +def _merge_resource_grants( + raw_resources: Json, specs: list[AbilitySpec] +) -> dict[str, int]: + """Merge the JSON resource pool with per-day grants from resolved features. + + Traits like Exalted of the Society ("You may channel energy one additional + time per day") store their benefit in ``AbilitySpec.resource_grants`` + because the engine keeps resource pools (``Combatant.resources`` -> + ``CombatantState.resources_left``), not stat modifiers, for per-day uses. + """ + merged: dict[str, int] = {} + if isinstance(raw_resources, dict): + merged.update( + { + key: value + for key, value in raw_resources.items() + if isinstance(value, int) and not isinstance(value, bool) + } + ) + for spec in specs: + for resource_name, amount in spec.resource_grants: + merged[resource_name] = merged.get(resource_name, 0) + amount + return merged + + def _auto_attack_ids(data: dict[str, Json], monster_id: str) -> None: """Auto-assign attack ids from file stem if missing.""" attacks = data.get("attacks") @@ -240,7 +265,11 @@ def load_combatant(path: Path) -> Combatant: raw_features = data.pop("features", None) validate_payload: dict[str, object] = dict(data) if isinstance(raw_features, list): - validate_payload["features"] = _resolve_features(raw_features, level) + specs = _resolve_features(raw_features, level) + validate_payload["features"] = specs + granted = _merge_resource_grants(data.get("resources"), specs) + if granted: + validate_payload["resources"] = granted # spells stays as list[str] — Pydantic validates it directly diff --git a/tests/test_feats.py b/tests/test_feats.py index 3141edb..d3ee9ac 100644 --- a/tests/test_feats.py +++ b/tests/test_feats.py @@ -13,6 +13,8 @@ from pathlib import Path from pf1e_simulator.abilities import ( COMBAT_REFLEXES, + EXALTED_SOCIETY, + FORTIFIED_DRINKER, MOUNTED_COMBAT, POINT_BLANK_SHOT, ) @@ -227,3 +229,27 @@ def test_every_pc_feature_resolves_to_expected_spec() -> None: def test_mapping_table_covers_exactly_the_8_pc_feats() -> None: assert set(_EXPECTED_RESOLUTION) == _pcs_features() + + +# ── SRD conformance: corrected feat/trait effects ───────────────────────────── + + +def test_exalted_of_the_society_grants_one_extra_channel_use() -> None: + """SRD (Faction Guide trait): "You may channel energy one additional time + per day." The grant rides resource_grants and the loader merges it into + the combatant's channel_energy pool (oni sheet base 6 -> 7).""" + assert EXALTED_SOCIETY.resource_grants == (("channel_energy", 1),) + assert not any( + e.target in ("knowledge_local", "knowledge_nobility") + for e in EXALTED_SOCIETY.effects + ) + oni = load_combatant(Path("data/pcs/oni.json")) + assert oni.resources["channel_energy"] == 7 + + +def test_fortified_drinker_is_inert_documented_noop() -> None: + """SRD: +2 saves vs mind-affecting for 1 hour after drinking alcohol. The + alcohol trigger is not modeled -> inert no-op (the old dead "+2 Fort vs + poison, condition='alcohol'" modifier is gone).""" + assert FORTIFIED_DRINKER.effects == () + assert "not modeled" in FORTIFIED_DRINKER.source_detail