fix(combat): evaluate conditional stat modifiers with action context

This commit is contained in:
2026-09-05 12:17:58 +02:00
parent 31d970dd5f
commit 2f2acd992f
4 changed files with 597 additions and 29 deletions
+79 -18
View File
@@ -93,7 +93,9 @@ from pf1e_simulator.spells import (
HealEffect,
SpellEffect,
SummonEffect,
save_descriptors_for_effects,
spell_range_ft,
spell_save_descriptors,
)
if TYPE_CHECKING:
@@ -123,6 +125,12 @@ _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
_POINT_BLANK_RANGE_FT = 30
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting",
})
_SIZE_MODIFIERS: dict[str, int] = {
@@ -409,15 +417,33 @@ class CombatEngine:
or any(cond.name == name for cond in state.conditions)
)
def _modifier_condition_holds(self, _state: CombatantState, _condition_name: str) -> bool:
def _modifier_condition_holds(
self,
_state: CombatantState,
condition_name: str,
*,
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
) -> bool:
"""Evaluate whether a StatModifier's runtime condition holds.
Returns False for unknown or unresolvable conditions (modifier excluded).
Currently no conditions are evaluable from _stat_modifiers call sites
alone (they require action-level context like target range or spell
descriptor), so this always returns False. When the engine gains that
context, specific conditions can be added here.
Unknown/unresolvable conditions return False (modifier excluded).
Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft),
"axe" (weapon name contains "axe"), and save-descriptor names (fear,
cold, ...) that are present in the save's descriptor set.
"""
if condition_name == "target_within_30ft_ranged":
return (
weapon is not None
and dist_ft is not None
and self._is_ranged_attack(weapon)
and dist_ft <= _POINT_BLANK_RANGE_FT
)
if condition_name == "axe":
return weapon is not None and "axe" in weapon.name.lower()
if condition_name in _SAVE_DESCRIPTOR_CONDITIONS:
return condition_name in save_descriptors
return False
def _get_speed_mult(self, state: CombatantState) -> float:
@@ -559,7 +585,14 @@ class CombatEngine:
return 0
def _stat_modifiers(
self, state: CombatantState, target: str, *, weapon_name: str | None = None
self,
state: CombatantState,
target: str,
*,
weapon_name: str | None = None,
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from all sources.
@@ -569,8 +602,9 @@ class CombatEngine:
Modifiers whose ``condition`` is set are only included when the named
runtime condition holds for ``state`` (evaluated via
``_modifier_condition_holds``). Unsupported condition names are treated
as NOT holding (modifier excluded).
``_modifier_condition_holds``, which consumes the weapon/range/save
context threaded from the action being resolved). Unsupported
condition names are treated as NOT holding (modifier excluded).
"""
mods: list[StatModifier] = []
# Old effects field (backwards compatibility)
@@ -600,7 +634,14 @@ class CombatEngine:
return [
m
for m in mods
if m.condition is None or self._modifier_condition_holds(state, m.condition)
if m.condition is None
or self._modifier_condition_holds(
state,
m.condition,
weapon=weapon,
dist_ft=dist_ft,
save_descriptors=save_descriptors,
)
]
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
@@ -642,7 +683,9 @@ class CombatEngine:
dist_ft = self._grid.distance(state.pos, enemy.pos) * _SQUARE_FT
if dist_ft > self._FEAR_AURA_RANGE_FT:
continue
save = self.resolve_save(enemy, "will", self._FEAR_AURA_DC)
save = self.resolve_save(
enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"})
)
if save.success:
self._log(
f"round {self._current_round} {state.combatant.id}: fear aura on "
@@ -693,7 +736,9 @@ class CombatEngine:
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
atk_mods.append(StatModifier(target="attack", value=-4))
attack_mod = resolve_modifiers(atk_mods)
@@ -742,7 +787,9 @@ class CombatEngine:
confirm = self._rng.d20()
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
crit = confirm != _NATURAL_ONE and confirm_total >= ac
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
damage += pa_amt * 2 if weapon.kind == "melee" else 0
@@ -775,18 +822,26 @@ class CombatEngine:
return max(0, damage - dr.amount)
def resolve_save(
self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int
self,
state: CombatantState,
save_type: Literal["fort", "ref", "will"],
dc: int,
*,
save_descriptors: frozenset[str] = frozenset(),
) -> SaveResult:
"""Roll a saving throw (fort/ref/will) against ``dc``.
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
Effect modifiers and condition modifiers (StatModifier with matching
target) are applied via ``resolve_modifiers`` — same bonus-type
stacking rules as attacks.
stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"},
{"poison"}) gates conditional modifiers like Bitter or Born Damned.
"""
roll = self._rng.d20()
base = getattr(state.combatant.saves, save_type)
bonus = resolve_modifiers(self._stat_modifiers(state, save_type))
bonus = resolve_modifiers(
self._stat_modifiers(state, save_type, save_descriptors=save_descriptors)
)
total = roll + base + bonus
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
return SaveResult(success=success, roll=roll, total=total, dc=dc)
@@ -1771,7 +1826,11 @@ class CombatEngine:
ability_mod = (ability_score - 10) // 2
dc = 10 + spell.level + ability_mod
save_result = (
self.resolve_save(target, spell.save, dc) if spell.save != "none" else None
self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
)
if spell.save != "none"
else None
)
caster_live = self._stats[caster.combatant.id]
target_live = self._stats[target.combatant.id]
@@ -2025,7 +2084,9 @@ class CombatEngine:
return False
if affected_state is target:
return saved
save_result = self.resolve_save(affected_state, "ref", dc)
save_result = self.resolve_save(
affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects)
)
return save_result.success
def _apply_metamagic(
+28
View File
@@ -108,6 +108,34 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
return 400 + 40 * caster_level
_FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering"})
def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]:
descriptors: set[str] = set()
for effect in effects:
if isinstance(effect, DamageEffect):
descriptors.update(effect.types)
elif isinstance(effect, ConditionEffect) and effect.condition_name in _FEAR_CONDITIONS:
descriptors.add("fear")
elif isinstance(effect, AreaEffect):
descriptors.update(save_descriptors_for_effects(effect.effects))
return frozenset(descriptors)
def spell_save_descriptors(spell: SpellSpec) -> frozenset[str]:
"""Spell save descriptors: effect-derived plus CRB school tags.
Enchantment is mind_affecting, divination is divination.
"""
descriptors = set(save_descriptors_for_effects(spell.effects))
if spell.school == "enchantment":
descriptors.add("mind_affecting")
elif spell.school == "divination":
descriptors.add("divination")
return frozenset(descriptors)
def _as_dict(value: Json) -> dict[str, Json]:
if isinstance(value, dict):
return value