feat(combat): enforce spell resistance checks and energy resistances

This commit is contained in:
2026-09-06 19:18:10 +02:00
parent a9ccdd3a56
commit a1c43e83af
4 changed files with 332 additions and 1 deletions
+64
View File
@@ -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
)