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:
+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).",
|
"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": [
|
"features": [
|
||||||
"Summon Good Monster",
|
"Summon Good Monster",
|
||||||
"Sacred Summons",
|
"Sacred Summons",
|
||||||
|
|||||||
@@ -130,6 +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é).",
|
||||||
"features": [
|
"features": [
|
||||||
"Weapon Focus (Pistol)",
|
"Weapon Focus (Pistol)",
|
||||||
"Rapid Reload (Pistol)",
|
"Rapid Reload (Pistol)",
|
||||||
|
|||||||
@@ -28,12 +28,18 @@ class AbilitySpec:
|
|||||||
``effects`` are the StatModifiers this ability grants. For passive feats,
|
``effects`` are the StatModifiers this ability grants. For passive feats,
|
||||||
these are always-on modifiers. For conditional feats, ``condition`` on
|
these are always-on modifiers. For conditional feats, ``condition`` on
|
||||||
each StatModifier gates applicability (evaluated by the engine).
|
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
|
name: str
|
||||||
category: Category
|
category: Category
|
||||||
source_detail: str = ""
|
source_detail: str = ""
|
||||||
effects: tuple[StatModifier, ...] = ()
|
effects: tuple[StatModifier, ...] = ()
|
||||||
|
resource_grants: tuple[tuple[str, int], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
# ── Passive combat feats ─────────────────────────────────────────────────────
|
# ── Passive combat feats ─────────────────────────────────────────────────────
|
||||||
@@ -351,14 +357,16 @@ EXTREMELY_FASHIONABLE = AbilitySpec(
|
|||||||
effects=(StatModifier(target="diplomacy", value=4),),
|
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(
|
EXALTED_SOCIETY = AbilitySpec(
|
||||||
name="Exalted of the Society (Cleric, Pathfinder Society)",
|
name="Exalted of the Society (Cleric, Pathfinder Society)",
|
||||||
category="trait",
|
category="trait",
|
||||||
effects=(
|
resource_grants=(("channel_energy", 1),),
|
||||||
StatModifier(target="knowledge_local", value=1),
|
|
||||||
StatModifier(target="knowledge_nobility", value=1),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bitter: +1 trait bonus on saves vs fear
|
# Bitter: +1 trait bonus on saves vs fear
|
||||||
@@ -387,13 +395,17 @@ SKILL_FOCUS_PERCEPTION = AbilitySpec(
|
|||||||
# Dazzling Display: intimidate as standard action
|
# Dazzling Display: intimidate as standard action
|
||||||
DAZZLING_DISPLAY = AbilitySpec(name="Dazzling Display", category="feat")
|
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(
|
FORTIFIED_DRINKER = AbilitySpec(
|
||||||
name="Fortified Drinker (Cayden Cailean)",
|
name="Fortified Drinker (Cayden Cailean)",
|
||||||
category="trait",
|
category="trait",
|
||||||
effects=(
|
source_detail="trait (not modeled: +2 saves vs mind-affecting after drinking)",
|
||||||
StatModifier(target="fort", value=2, condition="alcohol"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fate's Favored: +1 luck bonus on all luck bonuses
|
# Fate's Favored: +1 luck bonus on all luck bonuses
|
||||||
|
|||||||
@@ -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:
|
def _auto_attack_ids(data: dict[str, Json], monster_id: str) -> None:
|
||||||
"""Auto-assign attack ids from file stem if missing."""
|
"""Auto-assign attack ids from file stem if missing."""
|
||||||
attacks = data.get("attacks")
|
attacks = data.get("attacks")
|
||||||
@@ -240,7 +265,11 @@ def load_combatant(path: Path) -> Combatant:
|
|||||||
raw_features = data.pop("features", None)
|
raw_features = data.pop("features", None)
|
||||||
validate_payload: dict[str, object] = dict(data)
|
validate_payload: dict[str, object] = dict(data)
|
||||||
if isinstance(raw_features, list):
|
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
|
# spells stays as list[str] — Pydantic validates it directly
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from pf1e_simulator.abilities import (
|
from pf1e_simulator.abilities import (
|
||||||
COMBAT_REFLEXES,
|
COMBAT_REFLEXES,
|
||||||
|
EXALTED_SOCIETY,
|
||||||
|
FORTIFIED_DRINKER,
|
||||||
MOUNTED_COMBAT,
|
MOUNTED_COMBAT,
|
||||||
POINT_BLANK_SHOT,
|
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:
|
def test_mapping_table_covers_exactly_the_8_pc_feats() -> None:
|
||||||
assert set(_EXPECTED_RESOLUTION) == _pcs_features()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user