fix(combat): apply save negates to targeted spell damage effects

This commit is contained in:
2026-09-05 12:52:58 +02:00
parent cd75a4f2bf
commit 7cb3653750
4 changed files with 100 additions and 10 deletions
+11 -7
View File
@@ -364,7 +364,11 @@ Règles modélisées :
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) :
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé,
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de CON
(+4 avec le don Combat Casting). `default_policy` choisit soin / offensif / buff via
(+4 avec le don Combat Casting). Un sort ciblé avec sauvegarde fait pleins dégâts
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`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`.
- 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`
@@ -431,8 +435,6 @@ dans la résolution) :
utilisent une direction déterministe (premier voisin libre / au-delà de la
cible) ; un objet volé n'est pas récupérable en cours de combat. Une arme
brisée ne peut pas être réparée en cours de combat.
- La distribution des dégâts par type sur un sort n'envoie que des dégâts simples
(sans plage « half on save » pour les effets ciblés).
## Architecture
@@ -490,10 +492,12 @@ dans la résolution) :
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` :
résolution de sauvegarde, application des dégâts (demi-dégâts sur sauvegarde
réussie ; la RD ne s'applique pas aux dégâts de sorts), conditions, soins
(plafonnés à hp_max) et buffs. Le champ `spells` de `Combatant` contient les
noms de sorts connus ; le `spell_registry` est passé au `CombatEngine`.
résolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde
réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si
l'effet déclare `half_on_save` ; la RD ne s'applique pas aux dégâts de sorts),
conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de
`Combatant` contient les noms de sorts connus ; le `spell_registry` est passé
au `CombatEngine`.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
+6 -1
View File
@@ -2077,7 +2077,12 @@ class CombatEngine:
raw = effect.formula.roll(self._rng)
if maximize:
raw = effect.formula.max_value()
dmg = max(0, raw // 2) if saved and effect.half_on_save else raw
if saved and effect.half_on_save:
dmg = max(0, raw // 2)
elif saved:
dmg = 0
else:
dmg = raw
if empower:
dmg = int(dmg * 1.5)
target.hp -= dmg
+3 -2
View File
@@ -35,7 +35,8 @@ SaveType = Literal["fort", "ref", "will", "none"]
class DamageEffect:
formula: DiceExpr
types: tuple[str, ...]
half_on_save: bool = True
# False = save negates (targeted); True = half damage on save (ray/line/cone/AoE)
half_on_save: bool = False
@dataclass(frozen=True)
@@ -216,7 +217,7 @@ def _parse_effect(data: Json) -> SpellEffect:
return DamageEffect(
formula=parse_dice(_as_str(effect["formula"])),
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(effect.get("half_on_save", True)),
half_on_save=bool(effect.get("half_on_save", False)),
)
if effect_type == "condition":
return ConditionEffect(
+80
View File
@@ -32,6 +32,21 @@ from pf1e_simulator.spells import (
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
_SAVE_NEGATES_BOLT = SpellSpec(
name="Save Negates Bolt",
level=1,
school="evocation",
range="medium",
save="ref",
effects=(
DamageEffect(
formula=parse_dice("2d4"),
types=("force",),
half_on_save=False,
),
),
)
def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec(
@@ -244,6 +259,71 @@ class TestFireballSave:
assert any("SAVED" in line for line in engine._transcript)
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
When: the target succeeds / fails its save
Then: full damage on failure, ZERO damage on success (save negates)."""
def test_successful_save_deals_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 20 = success) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [20, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before
assert any("0 damage" in line for line in engine._transcript)
def test_failed_save_deals_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
# 1 save roll (natural 1 = failure) + 2 damage dice
engine, c, t = _make_engine(
caster, target, [1, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
)
hp_before = t.hp
engine._cast_spell(c, t, "Save Negates Bolt")
assert t.hp == hp_before - 6
class TestLightningBoltSave:
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True)
When: the target succeeds its save
Then: damage is still halved (half-on-save is declared for line effects)."""
def test_successful_save_still_halves(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
# 1 save roll (natural 20 = success) + 6 damage dice
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
class TestDamageEffectHalfOnSaveDefault:
"""Given: a DamageEffect constructed without half_on_save
When: the field is read
Then: it defaults to False (save negates for targeted effects)."""
def test_defaults_false(self) -> None:
effect = DamageEffect(formula=parse_dice("1d4"), types=("fire",))
assert effect.half_on_save is False
def test_loader_defaults_false(self, tmp_path) -> None:
spell_file = tmp_path / "test_spell.json"
spell_file.write_text(
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
' "range": "medium", "save": "ref", "effects": ['
'{"type": "damage", "formula": "1d4", "types": ["fire"]}]}'
)
spell = load_spell(spell_file)
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is False
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)