fix(combat): evaluate conditional stat modifiers with action context
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user