diff --git a/README.md b/README.md index ac36e0a..3c2fbf9 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a102b85..b7cd42b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index a5599fb..dde7be8 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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( diff --git a/tests/test_spells.py b/tests/test_spells.py index a14313e..723316c 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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)