feat(combat): enforce spell resistance checks and energy resistances
This commit is contained in:
@@ -842,6 +842,9 @@ class CombatEngine:
|
||||
"spells": tuple(
|
||||
self._spell_snapshot(state, name) for name in state.combatant.spells
|
||||
),
|
||||
"spell_resistance": state.combatant.spell_resistance,
|
||||
"resistances": dict(state.combatant.resistances),
|
||||
"immunities": tuple(state.combatant.immunities),
|
||||
}
|
||||
|
||||
def _pair_snapshot(
|
||||
@@ -1373,6 +1376,12 @@ class CombatEngine:
|
||||
if crit:
|
||||
damage *= weapon.crit_mult
|
||||
damage = self._apply_dr(damage, weapon, target)
|
||||
weapon_types = frozenset(
|
||||
t for comp in weapon.damage for t in comp.types
|
||||
)
|
||||
damage = self._apply_energy_resistance(
|
||||
damage, weapon_types, target
|
||||
)
|
||||
target.hp -= damage
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
@@ -1427,6 +1436,55 @@ class CombatEngine:
|
||||
return damage
|
||||
return max(0, damage - dr.amount)
|
||||
|
||||
def _check_spell_resistance(
|
||||
self, caster: CombatantState, target: CombatantState
|
||||
) -> bool:
|
||||
"""SR check: d20 + caster level vs target spell_resistance.
|
||||
|
||||
Returns True if the spell affects the target (SR passed or no SR).
|
||||
Returns False if the target resists the spell (all effects skipped).
|
||||
"""
|
||||
sr = target.combatant.spell_resistance
|
||||
if sr == 0:
|
||||
return True
|
||||
caster_level = caster.combatant.level
|
||||
roll = self._rng.d20()
|
||||
total = roll + caster_level
|
||||
if total >= sr:
|
||||
return True
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"spell resistance {target.combatant.id}: "
|
||||
f"d20({roll}) + CL({caster_level}) = {total} vs SR {sr} "
|
||||
f"— resisted"
|
||||
)
|
||||
return False
|
||||
|
||||
def _apply_energy_resistance(
|
||||
self,
|
||||
damage: int,
|
||||
damage_types: frozenset[str],
|
||||
target: CombatantState,
|
||||
) -> int:
|
||||
"""Energy resistance / immunity at the final-damage stage.
|
||||
|
||||
Immunity: if any damage type matches → 0. Resistance: subtract the
|
||||
max resistance among matching energy types, floored at 0.
|
||||
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
|
||||
they stay on the DR path.
|
||||
"""
|
||||
immunities = target.combatant.immunities
|
||||
if immunities and damage_types & frozenset(immunities):
|
||||
return 0
|
||||
resistances = target.combatant.resistances
|
||||
if not resistances:
|
||||
return damage
|
||||
max_resist = max(
|
||||
(resistances[t] for t in damage_types if t in resistances),
|
||||
default=0,
|
||||
)
|
||||
return max(0, damage - max_resist)
|
||||
|
||||
def resolve_save(
|
||||
self,
|
||||
state: CombatantState,
|
||||
@@ -2890,6 +2948,9 @@ class CombatEngine:
|
||||
if self._check_casting_aoo(caster, spell.level):
|
||||
return
|
||||
caster.spend_spell_slot(spell.level)
|
||||
_has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
||||
if not _has_area and not self._check_spell_resistance(caster, target):
|
||||
return
|
||||
dc = self._spell_dc(caster, spell)
|
||||
save_result = (
|
||||
self.resolve_save(
|
||||
@@ -3023,6 +3084,7 @@ class CombatEngine:
|
||||
dmg = raw
|
||||
if empower:
|
||||
dmg = int(dmg * 1.5)
|
||||
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||
target.hp -= dmg
|
||||
caster_live.damage_dealt += dmg
|
||||
target_live.damage_taken += dmg
|
||||
@@ -3115,6 +3177,8 @@ class CombatEngine:
|
||||
total_buffs = 0
|
||||
|
||||
for affected_state in affected:
|
||||
if not self._check_spell_resistance(caster, affected_state):
|
||||
continue
|
||||
creature_saved = self._area_save(
|
||||
affected_state, target, effect, saved=saved, dc=dc
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user