fix(combat): apply save negates to targeted spell damage effects
This commit is contained in:
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user