diff --git a/README.md b/README.md index ca8dbed..7d0687c 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,8 @@ Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou par incrément complet au-delà du premier ; une arme `ranged` reste utilisable jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels. +`spell_resistance` (entier), `resistances` (dict type → montant) et +`immunities` (liste de types) sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc. ### Fiches de personnages (PJ) @@ -396,6 +398,18 @@ Règles modélisées : (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` pour les explosions). `default_policy` choisit soin / offensif / buff via `_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`. +- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant + avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet + `d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est + résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à + zone, la SR est résolue par créature touchée (pas seulement sur la cible + primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont + annulés par l'immunité (`immunities`) ou réduits par la résistance du type + correspondant (`resistances` : soustraction du maximum parmi les types du dégât, + plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le + chemin de la RD. La résistance énergétique s'applique au dégât final (même étape + que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des + armes. - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` (`_expire_summons` décrémente puis retire). @@ -480,7 +494,9 @@ Règles modélisées : feature_details (C4 : tuple de {name, category, source_detail, effects: tuple de {target, value, bonus_type, condition, weapon_filter}}), spells (résumés enrichis du registre : name, level, school, range_ft, - save, dc, summary, half_on_save, save_negates (E4)). + save, dc, summary, half_on_save, save_negates (E4)), + spell_resistance (int), resistances {type: montant}, immunities + (tuple triée des types immunisés). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1b7b4ff..ad41127 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 ) diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 783b4b0..1edce90 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -294,3 +294,6 @@ class Combatant(_FrozenModel): resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell spells: list[str] = Field(default_factory=list) + spell_resistance: int = 0 + resistances: dict[str, int] = Field(default_factory=dict) + immunities: tuple[str, ...] = () diff --git a/tests/test_spell_resistance.py b/tests/test_spell_resistance.py new file mode 100644 index 0000000..9e0c06f --- /dev/null +++ b/tests/test_spell_resistance.py @@ -0,0 +1,248 @@ +"""Spell resistance and energy resistance/immunity (Package P1).""" +from __future__ import annotations + +from pf1e_simulator.combat import CombatEngine +from pf1e_simulator.dice import parse_dice +from pf1e_simulator.models import ( + AbilityScores, + ACProfile, + AttackSpec, + Combatant, + DamageComponent, + Saves, +) +from pf1e_simulator.rng import ScriptedRng +from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec +from tests.test_observation_enriched import _snapshot, make_grid, make_state + + +def _make_combatant( + id_: str, + *, + hp_max: int = 100, + attacks: tuple[AttackSpec, ...] = (), + spell_resistance: int = 0, + resistances: dict[str, int] | None = None, + immunities: tuple[str, ...] = (), +) -> Combatant: + return Combatant( + id=id_, + name=id_, + level=1, + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + ac=ACProfile(total=10, touch=10, flat_footed=10), + bab=1, + initiative_mod=0, + speed_land_ft=30, + hp_max=hp_max, + saves=Saves(fort=0, ref=0, will=0), + attacks=list(attacks), + spell_resistance=spell_resistance, + resistances=resistances or {}, + immunities=immunities, + ) + + +def _engine( + combatants: list[Combatant], + positions: list[tuple[int, int]], + sides: list[str], + rng: list[int], + spell_registry: dict[str, SpellSpec] | None = None, +) -> CombatEngine: + states = [ + make_state(c, side, pos) + for c, side, pos in zip(combatants, sides, positions, strict=True) + ] + return CombatEngine( + ScriptedRng(rng), make_grid(), states, + spell_registry=spell_registry, + ) + + +_MAGIC_MISSILE = SpellSpec( + name="Magic Missile", + level=1, + school="evocation", + range="medium", + save="none", + base_dc=10, + effects=( + DamageEffect( + formula=parse_dice("1d4+1"), + types=("force",), + ), + ), +) + +_FIREBALL = SpellSpec( + name="Fireball", + level=3, + school="evocation", + range="long", + save="ref", + base_dc=10, + effects=( + AreaEffect( + radius_ft=20, + effects=( + DamageEffect( + formula=parse_dice("8d6"), + types=("fire",), + half_on_save=True, + ), + ), + save_for_half=True, + ), + ), +) + + +def test_sr_blocks_all_effects() -> None: + caster = _make_combatant("caster", spell_resistance=0) + target = _make_combatant("target", spell_resistance=15) + eng = _engine( + [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], + [8], + spell_registry={"Magic Missile": _MAGIC_MISSILE}, + ) + cs = eng._states[0] + ts = eng._states[1] + eng._cast_spell(cs, ts, "Magic Missile") + assert ts.hp == 100 + + +def test_sr_passes_when_cl_suffices() -> None: + caster = _make_combatant("caster", spell_resistance=0) + target = _make_combatant("target", spell_resistance=10) + eng = _engine( + [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], + [10, 3], + spell_registry={"Magic Missile": _MAGIC_MISSILE}, + ) + cs = eng._states[0] + ts = eng._states[1] + eng._cast_spell(cs, ts, "Magic Missile") + assert ts.hp < 100 + + +def test_per_target_sr_in_area_spell() -> None: + caster = _make_combatant("caster", spell_resistance=0) + t1 = _make_combatant("t1", spell_resistance=15) + t2 = _make_combatant("t2", spell_resistance=0) + eng = _engine( + [caster, t1, t2], + [(0, 0), (0, 3), (0, 5)], + ["players", "monsters", "monsters"], + [20, 3, 4, 4, 4, 4, 4, 4, 4, 4], + spell_registry={"Fireball": _FIREBALL}, + ) + cs = eng._states[0] + ts1 = eng._states[1] + ts2 = eng._states[2] + eng._cast_spell(cs, ts2, "Fireball") + assert ts1.hp == 100 + assert ts2.hp < 100 + + +def test_no_sr_check_when_sr_zero() -> None: + caster = _make_combatant("caster", spell_resistance=0) + target = _make_combatant("target", spell_resistance=0) + eng = _engine( + [caster, target], [(0, 0), (0, 2)], ["players", "monsters"], + [3], + spell_registry={"Magic Missile": _MAGIC_MISSILE}, + ) + cs = eng._states[0] + ts = eng._states[1] + eng._cast_spell(cs, ts, "Magic Missile") + assert ts.hp == 96 + assert not any("spell resistance" in line for line in eng._transcript) + + +def test_fire_resistance_reduces_spell_damage() -> None: + caster = _make_combatant("caster", spell_resistance=0) + target = _make_combatant( + "target", + spell_resistance=0, + resistances={"fire": 5}, + ) + eng = _engine( + [caster, target], [(0, 0), (0, 5)], ["players", "monsters"], + [1, 3, 3, 3, 3, 3, 3, 3, 3], + spell_registry={"Fireball": _FIREBALL}, + ) + cs = eng._states[0] + ts = eng._states[1] + eng._cast_spell(cs, ts, "Fireball") + assert ts.hp < 100 + + +def test_fire_immunity_negates_damage() -> None: + caster = _make_combatant("caster", spell_resistance=0) + target = _make_combatant( + "target", + spell_resistance=0, + immunities=("fire",), + ) + eng = _engine( + [caster, target], [(0, 0), (0, 5)], ["players", "monsters"], + [1, 3, 3, 3, 3, 3, 3, 3, 3], + spell_registry={"Fireball": _FIREBALL}, + ) + cs = eng._states[0] + ts = eng._states[1] + eng._cast_spell(cs, ts, "Fireball") + assert ts.hp == 100 + + +def test_weapon_fire_damage_reduced_by_resistance() -> None: + fire_weapon = AttackSpec( + id="flaming-sword", + name="flaming sword", + kind="melee", + attack_bonus=20, + damage=( + DamageComponent(formula=parse_dice("1d8"), types=("slashing",)), + DamageComponent(formula=parse_dice("1d6"), types=("fire",)), + ), + damage_bonus=0, + crit_range=20, + crit_mult=2, + ) + hero = _make_combatant("hero", attacks=(fire_weapon,)) + foe = _make_combatant( + "foe", + resistances={"fire": 10}, + ) + eng = _engine( + [hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], + [15, 7, 6], + ) + hs = eng._states[0] + fs = eng._states[1] + eng._single_attack(hs, fs, fire_weapon) + assert fs.hp < 100 + + +def test_snapshot_exposes_new_keys() -> None: + hero = _make_combatant( + "hero", + spell_resistance=15, + resistances={"fire": 10, "cold": 5}, + immunities=("electricity",), + ) + foe = _make_combatant("foe") + eng = _engine( + [hero, foe], [(0, 0), (0, 3)], ["players", "monsters"], + [], + ) + hs = eng._states[0] + obs = _snapshot(eng, hs) + c = obs["combatants"][0] + assert c["spell_resistance"] == 15 + assert c["resistances"] == {"fire": 10, "cold": 5} + assert c["immunities"] == ("electricity",)