diff --git a/README.md b/README.md index eac35d0..f97de5b 100644 --- a/README.md +++ b/README.md @@ -1040,10 +1040,13 @@ Règles modélisées : total scalé suit ensuite le critique, la résistance énergétique et la métamagie comme n'importe quelle formule (le chargeur refuse un dé de scalage de type différent du dé de base). Absence de scalage = comportement - inchangé. Simplification documentée : le scalage ne s'applique qu'aux - dégâts (`DamageEffect`) — les soins par niveau (Cure Light Wounds - « +1 per caster level (maximum +5) ») ne sont pas scalés (la fiche dorée - épingle `1d8+1`). + inchangé. Le scalage s'applique aussi aux soins (S5b) : un `HealEffect` + porte le même `scaling_flat` (bonus plat par niveau) résolu par + `effective_formula(niveau)` à l'incantation — Cure Light Wounds + « 1d8 + 1 per caster level (maximum +5) » et Cure Moderate Wounds + « 2d8 + 1 per caster level (maximum +10) » (SRD spells/cure-light-wounds.md, + spells/cure-moderate-wounds.md) sont désormais scalés au lieu d'épinger + une formule figée. - Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer `"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels` @@ -1657,9 +1660,10 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : (rayons), temps d'incantation `cast_time`) et types d'effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un - `DamageEffect` porte des specs de scalage optionnels (`scaling` en dés, - `scaling_flat` en bonus plat) résolues par `effective_formula(niveau)` au - moment de l'incantation (S1b). Portées PF1e (personal, touch, close, medium, + `DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage + optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par + `effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e + (personal, touch, close, medium, long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, `load_spell_registry`) pour `data/spells/`, acceptant les codes de budget `cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des diff --git a/data/spells/cure_light_wounds.json b/data/spells/cure_light_wounds.json index 8fcef8a..9ca3c61 100644 --- a/data/spells/cure_light_wounds.json +++ b/data/spells/cure_light_wounds.json @@ -9,8 +9,9 @@ "effects": [ { "type": "heal", - "formula": "1d8+1" + "formula": "1d8", + "scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 } } ], - "description": "Cures 1d8+1 points of damage. Will save negates (harmless)." + "description": "Cures 1d8 points of damage +1 point per caster level (maximum +5). Will save negates (harmless)." } diff --git a/data/spells/cure_moderate_wounds.json b/data/spells/cure_moderate_wounds.json index e23049e..898201e 100644 --- a/data/spells/cure_moderate_wounds.json +++ b/data/spells/cure_moderate_wounds.json @@ -9,8 +9,9 @@ "effects": [ { "type": "heal", - "formula": "2d8+3" + "formula": "2d8", + "scaling_flat": { "amount": 1, "per_levels": 1, "max": 10 } } ], - "description": "Cures 2d8+3 points of damage. Will save negates (harmless)." + "description": "Cures 2d8 points of damage +1 point per caster level (maximum +10). Will save negates (harmless)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 92befd8..38d6ac0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6202,7 +6202,10 @@ class CombatEngine: if isinstance(effect, ConditionEffect): return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) if isinstance(effect, HealEffect): - return self._apply_heal_effect(effect, target, empower=empower, maximize=maximize) + return self._apply_heal_effect( + effect, target, caster_level=caster.combatant.level, + empower=empower, maximize=maximize, + ) return self._apply_buff_effect(effect, target, spell_name=spell_name) def _apply_damage_effect( @@ -6386,12 +6389,14 @@ class CombatEngine: effect: HealEffect, target: CombatantState, *, + caster_level: int, empower: bool, maximize: bool, ) -> str: - heal = effect.formula.roll(self._rng) + formula = effect.effective_formula(caster_level) + heal = formula.roll(self._rng) if maximize: - heal = effect.formula.max_value() + heal = formula.max_value() if empower: heal = int(heal * 1.5) old_hp = target.hp diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 521917a..0b80188 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -182,6 +182,27 @@ class ConditionEffect: @dataclass(frozen=True) class HealEffect: formula: DiceExpr + # PF1e caster-level scaling for healing (SRD spells/cure-light-wounds.md: + # "cures 1d8 points of damage +1 point per caster level (maximum +5)" and + # cure-moderate-wounds.md: "cures 2d8 points of damage +1 point per caster + # level (maximum +10)"). Absent = level-independent healing, the pre-S5b + # behaviour. + scaling_flat: DamageFlatScaling | None = None + + def effective_formula(self, caster_level: int) -> DiceExpr: + """The healing formula resolved for a caster of ``caster_level``. + + PF1e (CRB, Magic — Caster Level): the flat bonus grows with the caster + and stops at the stated cap — bonus = min(max, (level // per_levels) + * amount), mirroring ``DamageEffect.effective_formula``. + """ + bonus = self.formula.bonus + if self.scaling_flat is not None: + bonus += min( + self.scaling_flat.max_amount, + (caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount, + ) + return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus) @dataclass(frozen=True) @@ -529,8 +550,11 @@ def _parse_effect(data: Json) -> SpellEffect: save_partial=save_partial, ) if effect_type == "heal": + flat_raw = effect.get("scaling_flat") + scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) return HealEffect( formula=parse_dice(_as_str(effect["formula"])), + scaling_flat=scaling_flat, ) if effect_type == "buff": return BuffEffect( diff --git a/tests/test_bleeding_confused.py b/tests/test_bleeding_confused.py index 22fbda4..59cd06e 100644 --- a/tests/test_bleeding_confused.py +++ b/tests/test_bleeding_confused.py @@ -102,7 +102,8 @@ def test_heal_spell_clears_bleed() -> None: _add_condition(a, "bleeding") a.conditions.append(BLEEDING) result = eng._apply_heal_effect( - HealEffect(formula=parse_dice("1d8")), a, empower=False, maximize=False + HealEffect(formula=parse_dice("1d8")), a, caster_level=1, + empower=False, maximize=False, ) assert result == "+5 HP" assert a.hp == 55 diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py index 2b1bb6a..bcadce4 100644 --- a/tests/test_dying_and_crits.py +++ b/tests/test_dying_and_crits.py @@ -220,7 +220,8 @@ def test_heal_spell_stabilizes_dying_creature() -> None: s.hp = -3 eng._rng = ScriptedRng([1]) # 1d8 -> 1: only 1 point cured result = eng._apply_heal_effect( - HealEffect(formula=parse_dice("1d8")), s, empower=False, maximize=False + HealEffect(formula=parse_dice("1d8")), s, caster_level=1, + empower=False, maximize=False, ) assert result == "+1 HP" assert s.hp == -2 @@ -251,7 +252,8 @@ def test_heal_to_positive_hp_does_not_stabilize() -> None: s.hp = -3 eng._rng = ScriptedRng([8]) eng._apply_heal_effect( - HealEffect(formula=parse_dice("1d8")), s, empower=False, maximize=False + HealEffect(formula=parse_dice("1d8")), s, caster_level=1, + empower=False, maximize=False, ) assert s.hp == 5 assert s.stabilized_hp is None diff --git a/tests/test_heal_scaling.py b/tests/test_heal_scaling.py new file mode 100644 index 0000000..7ebef8b --- /dev/null +++ b/tests/test_heal_scaling.py @@ -0,0 +1,232 @@ +"""Caster-level healing scaling (Package S5b). + +PF1e (SRD spells/cure-light-wounds.md): "cures 1d8 points of damage + 1 point +per caster level (maximum +5)"; (spells/cure-moderate-wounds.md): "cures 2d8 +points of damage + 1 point per caster level (maximum +10)". A HealEffect may +declare ``"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}`` reusing +the S1b ``DamageFlatScaling`` machinery; the cast resolves it against the +caster's level. + +Unit tests pin ``HealEffect.effective_formula`` arithmetic and the loader; +integration casts pin that the scaled formula — and ONLY the scaled formula — +drives the dice consumed and Maximize Spell. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.dice import parse_dice +from pf1e_simulator.grid import Grid +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.rng import ScriptedRng +from pf1e_simulator.spells import ( + DamageFlatScaling, + HealEffect, + SpellSpec, + load_spell, +) +from tests.test_observation_enriched import make_combatant, make_state + +if TYPE_CHECKING: + from pf1e_simulator.models import Combatant + +_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells" + + +# --------------------------------------------------------------------------- +# unit: effective_formula arithmetic +# --------------------------------------------------------------------------- + + +def test_flat_scaling_grows_with_level_and_stops_at_cap() -> None: + """Given: 1d8 + 1 per caster level, maximum +5 (SRD Cure Light Wounds) + When: effective_formula resolves for levels 0/1/4/7 + Then: 1d8, 1d8+1, 1d8+4, 1d8+5 — the cap binds, the base dice untouched.""" + effect = HealEffect( + formula=parse_dice("1d8"), + scaling_flat=DamageFlatScaling(amount=1, per_levels=1, max_amount=5), + ) + assert effect.effective_formula(0) == parse_dice("1d8") + assert effect.effective_formula(1) == parse_dice("1d8+1") + assert effect.effective_formula(4) == parse_dice("1d8+4") + assert effect.effective_formula(7) == parse_dice("1d8+5") + + +def test_no_scaling_is_behaviour_change_free() -> None: + """Given: a HealEffect without a scaling_flat spec + When: effective_formula resolves at any level + Then: the base formula is returned unchanged.""" + effect = HealEffect(formula=parse_dice("1d8+2")) + assert effect.effective_formula(0) == parse_dice("1d8+2") + assert effect.effective_formula(20) == parse_dice("1d8+2") + + +def test_loader_parses_heal_scaling_flat(tmp_path: Path) -> None: + """Given: heal effects with and without a "scaling_flat" spec + When: load_spell parses them + Then: the spec lands on the HealEffect; the plain heal keeps None.""" + spell_file = tmp_path / "scaled_heal.json" + spell_file.write_text( + '{"name": "Scaled Heal", "level": 1, "school": "conjuration",' + ' "range": "touch", "save": "will", "effects": [' + ' {"type": "heal", "formula": "1d8",' + ' "scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}},' + ' {"type": "heal", "formula": "1"}]}' + ) + spell = load_spell(spell_file) + scaled, plain = spell.effects + assert isinstance(scaled, HealEffect) + assert isinstance(plain, HealEffect) + assert scaled.scaling_flat == DamageFlatScaling( + amount=1, per_levels=1, max_amount=5 + ) + assert plain.scaling_flat is None + + +def test_loader_rejects_bad_heal_scaling_flat(tmp_path: Path) -> None: + """Given: a heal scaling_flat with per_levels < 1 + When: load_spell parses it + Then: it is a data error (the shared S1b validator refuses).""" + spell_file = tmp_path / "bad_heal.json" + spell_file.write_text( + '{"name": "Bad Heal", "level": 1, "school": "conjuration",' + ' "range": "touch", "save": "will", "effects": [' + ' {"type": "heal", "formula": "1d8",' + ' "scaling_flat": {"amount": 1, "per_levels": 0, "max": 5}}]}' + ) + with pytest.raises(ValueError, match="per_levels must be >= 1"): + load_spell(spell_file) + + +# --------------------------------------------------------------------------- +# data: the SRD values are pinned on the shipped spell files +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("filename", "base", "cap"), + [("cure_light_wounds.json", "1d8", 5), ("cure_moderate_wounds.json", "2d8", 10)], +) +def test_cure_spells_carry_srd_scaling(filename: str, base: str, cap: int) -> None: + """Given: the shipped cure spell JSON files + When: they are loaded + Then: the base dice carry no baked-in flat bonus and scaling_flat is + +1 per level capped at the SRD maximum (spells/cure-light-wounds.md, + spells/cure-moderate-wounds.md).""" + spell = load_spell(_SPELLS_DIR / filename) + (effect,) = spell.effects + assert isinstance(effect, HealEffect) + assert effect.formula == parse_dice(base) + assert effect.scaling_flat is not None + assert effect.scaling_flat.amount == 1 + assert effect.scaling_flat.per_levels == 1 + assert effect.scaling_flat.max_amount == cap + + +# --------------------------------------------------------------------------- +# integration: the scaled formula drives the cast +# --------------------------------------------------------------------------- + + +def _heal_spell(name: str, formula: str, cap: int, level: int) -> SpellSpec: + return SpellSpec( + name=name, + level=level, + school="conjuration", + range="touch", + save="will", + effects=( + HealEffect( + formula=parse_dice(formula), + scaling_flat=DamageFlatScaling( + amount=1, per_levels=1, max_amount=cap + ), + ), + ), + ) + + +_CLW = _heal_spell("Cure Light Wounds", "1d8", 5, 1) +_CMW = _heal_spell("Cure Moderate Wounds", "2d8", 10, 2) + + +def _cast_heal( + caster: Combatant, + target: Combatant, + spell: SpellSpec, + start_hp: int, + rolls: list[int], + *, + metamagic: tuple[str, ...] = (), +) -> tuple[CombatEngine, CombatantState]: + """Cast a touch heal on a same-side ally (no delivery attack needed). + + The queue is [Will save, heal dice]: the save is rolled (harmless spells + keep their save line) and a scripted 1 always fails it, which for a + harmless effect means the healing is NOT negated. + """ + legend = {".": TerrainType(type="floor", move_cost=1)} + grid = Grid.from_spec( + MapSpec(name="open", terrain=tuple(["." * 8] * 8), legend=legend) + ) + c = make_state(caster, "players", (0, 0)) + t = make_state(target, "players", (1, 0)) + t.hp = start_hp + engine = CombatEngine( + ScriptedRng(rolls), grid, [c, t], spell_registry={spell.name: spell} + ) + engine._cast_spell(c, t, spell.name, metamagic=metamagic) + return engine, t + + +def _caster(level: int, spell: SpellSpec, *, slots: dict[int, int]) -> Combatant: + caster = make_combatant("healer", spells=[spell.name], spell_slots=slots) + return caster.model_copy(update={"level": level}) + + +def test_cast_heals_with_flat_bonus_at_low_level() -> None: + """Given: a level-4 caster curing 1d8 + 1/level (max +5), ally at 10/30 + When: the scripted d8 rolls 3 + Then: the ally gains 3 + 4 = 7 hp — the level bonus, not the cap.""" + caster = _caster(4, _CLW, slots={1: 1}) + target = make_combatant("foe", hp=30) + _, t = _cast_heal(caster, target, _CLW, 10, [1, 3]) + assert t.hp == 17 + + +def test_cast_heal_scales_and_caps_at_max() -> None: + """Given: a level-12 caster (scaling would give +12, cap +5), ally at 28/30 + When: the scripted d8 rolls 8 (→ 8+5 = 13 healed) + Then: the ally reaches hp_max — healing never overheals.""" + caster = _caster(12, _CLW, slots={1: 1}) + target = make_combatant("foe", hp=30) + _, t = _cast_heal(caster, target, _CLW, 28, [1, 8]) + assert t.hp == 30 + + +def test_moderate_cure_scales_to_its_own_cap() -> None: + """Given: a level-8 caster of 2d8 + 1/level (max +10), ally at 50/100 + When: the scripted 2d8 roll 4+4 = 8 + Then: the heal is 8 + 8 = 16 — the level bonus, not the light cap.""" + caster = _caster(8, _CMW, slots={2: 1}) + target = make_combatant("foe", hp=100) + _, t = _cast_heal(caster, target, _CMW, 50, [1, 4, 4]) + assert t.hp == 66 + + +def test_maximize_metamagic_maximizes_the_scaled_heal() -> None: + """Given: a level-3 caster who knows Maximize Spell curing 1d8 + 1/level + When: the cast is maximized (slot 1 + adjustment 3 = slot 4) + Then: the SCALED maximum (8 + 3 = 11) is healed — the scripted die is + consumed but its value is overwritten (engine convention).""" + caster = _caster(3, _CLW, slots={4: 1}).model_copy( + update={"metamagic_feats": ["Maximize Spell"]} + ) + target = make_combatant("foe", hp=30) + _, t = _cast_heal(caster, target, _CLW, 10, [1, 1], metamagic=("Maximize Spell",)) + assert t.hp == 21 diff --git a/tests/test_spells.py b/tests/test_spells.py index 39e30e5..ae18a79 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -588,7 +588,9 @@ class TestCureLightWounds: target_side="players") t.hp = 10 engine._cast_spell(c, t, "Cure Light Wounds") - assert t.hp == 10 + (5 + 1) + # Caster level 5: flat bonus = min(5, (5 // 1) * 1) = 5 (SRD + # cure-light-wounds.md: "+1 point per caster level (maximum +5)"). + assert t.hp == 10 + (5 + 5) def test_heal_capped_at_max(self) -> None: caster = _make_caster()