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)

This commit is contained in:
2026-09-19 10:37:23 +02:00
parent 1a68dfa6db
commit d0db208610
5 changed files with 79 additions and 11 deletions
+21 -9
View File
@@ -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
+30 -1
View File
@@ -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