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`) : 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é, `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 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`. `_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 - 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`
@@ -431,8 +435,6 @@ dans la résolution) :
utilisent une direction déterministe (premier voisin libre / au-delà de la 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 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. 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 ## Architecture
@@ -490,10 +492,12 @@ dans la résolution) :
conditions de peur, écoles enchantement/divination) sont dérivés des effets conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` : 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ésolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde
réussie ; la RD ne s'applique pas aux dégâts de sorts), conditions, soins réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si
(plafonnés à hp_max) et buffs. Le champ `spells` de `Combatant` contient les l'effet déclare `half_on_save` ; la RD ne s'applique pas aux dégâts de sorts),
noms de sorts connus ; le `spell_registry` est passé au `CombatEngine`. 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`, - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]). `win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone + - `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) raw = effect.formula.roll(self._rng)
if maximize: if maximize:
raw = effect.formula.max_value() 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: if empower:
dmg = int(dmg * 1.5) dmg = int(dmg * 1.5)
target.hp -= dmg target.hp -= dmg
+3 -2
View File
@@ -35,7 +35,8 @@ SaveType = Literal["fort", "ref", "will", "none"]
class DamageEffect: class DamageEffect:
formula: DiceExpr formula: DiceExpr
types: tuple[str, ...] 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) @dataclass(frozen=True)
@@ -216,7 +217,7 @@ def _parse_effect(data: Json) -> SpellEffect:
return DamageEffect( return DamageEffect(
formula=parse_dice(_as_str(effect["formula"])), formula=parse_dice(_as_str(effect["formula"])),
types=tuple(_as_str(t) for t in _as_list(effect["types"])), 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": if effect_type == "condition":
return ConditionEffect( return ConditionEffect(
+80
View File
@@ -32,6 +32,21 @@ from pf1e_simulator.spells import (
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells" _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: def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec( attack = AttackSpec(
@@ -244,6 +259,71 @@ class TestFireballSave:
assert any("SAVED" in line for line in engine._transcript) 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: class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed) """Given: a caster casting Hold Person (will negates, paralyzed)