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`) :
|
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 +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user