feat(combat): enforce spell resistance checks and energy resistances
This commit is contained in:
@@ -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
|
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
|
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.
|
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.
|
Deux monstres d'exemple sont fournis : gobelin et orc.
|
||||||
|
|
||||||
### Fiches de personnages (PJ)
|
### 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`
|
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
||||||
pour les explosions). `default_policy` choisit soin / offensif / buff via
|
pour les explosions). `default_policy` choisit soin / offensif / buff via
|
||||||
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
|
`_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
|
- 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`
|
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||||
(`_expire_summons` décrémente puis retire).
|
(`_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:
|
feature_details (C4 : tuple de {name, category, source_detail, effects:
|
||||||
tuple de {target, value, bonus_type, condition, weapon_filter}}),
|
tuple de {target, value, bonus_type, condition, weapon_filter}}),
|
||||||
spells (résumés enrichis du registre : name, level, school, range_ft,
|
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,
|
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
||||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||||
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
||||||
|
|||||||
@@ -842,6 +842,9 @@ class CombatEngine:
|
|||||||
"spells": tuple(
|
"spells": tuple(
|
||||||
self._spell_snapshot(state, name) for name in state.combatant.spells
|
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(
|
def _pair_snapshot(
|
||||||
@@ -1373,6 +1376,12 @@ class CombatEngine:
|
|||||||
if crit:
|
if crit:
|
||||||
damage *= weapon.crit_mult
|
damage *= weapon.crit_mult
|
||||||
damage = self._apply_dr(damage, weapon, target)
|
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
|
target.hp -= damage
|
||||||
damage = self._consume_up_close_and_deadly(
|
damage = self._consume_up_close_and_deadly(
|
||||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||||
@@ -1427,6 +1436,55 @@ class CombatEngine:
|
|||||||
return damage
|
return damage
|
||||||
return max(0, damage - dr.amount)
|
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(
|
def resolve_save(
|
||||||
self,
|
self,
|
||||||
state: CombatantState,
|
state: CombatantState,
|
||||||
@@ -2890,6 +2948,9 @@ class CombatEngine:
|
|||||||
if self._check_casting_aoo(caster, spell.level):
|
if self._check_casting_aoo(caster, spell.level):
|
||||||
return
|
return
|
||||||
caster.spend_spell_slot(spell.level)
|
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)
|
dc = self._spell_dc(caster, spell)
|
||||||
save_result = (
|
save_result = (
|
||||||
self.resolve_save(
|
self.resolve_save(
|
||||||
@@ -3023,6 +3084,7 @@ class CombatEngine:
|
|||||||
dmg = raw
|
dmg = raw
|
||||||
if empower:
|
if empower:
|
||||||
dmg = int(dmg * 1.5)
|
dmg = int(dmg * 1.5)
|
||||||
|
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||||
target.hp -= dmg
|
target.hp -= dmg
|
||||||
caster_live.damage_dealt += dmg
|
caster_live.damage_dealt += dmg
|
||||||
target_live.damage_taken += dmg
|
target_live.damage_taken += dmg
|
||||||
@@ -3115,6 +3177,8 @@ class CombatEngine:
|
|||||||
total_buffs = 0
|
total_buffs = 0
|
||||||
|
|
||||||
for affected_state in affected:
|
for affected_state in affected:
|
||||||
|
if not self._check_spell_resistance(caster, affected_state):
|
||||||
|
continue
|
||||||
creature_saved = self._area_save(
|
creature_saved = self._area_save(
|
||||||
affected_state, target, effect, saved=saved, dc=dc
|
affected_state, target, effect, saved=saved, dc=dc
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -294,3 +294,6 @@ class Combatant(_FrozenModel):
|
|||||||
resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat
|
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
|
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
|
||||||
spells: list[str] = Field(default_factory=list)
|
spells: list[str] = Field(default_factory=list)
|
||||||
|
spell_resistance: int = 0
|
||||||
|
resistances: dict[str, int] = Field(default_factory=dict)
|
||||||
|
immunities: tuple[str, ...] = ()
|
||||||
|
|||||||
@@ -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",)
|
||||||
Reference in New Issue
Block a user