feat(rules): scale healing spells with caster level

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 now carries the same DamageFlatScaling spec as damage
effects (S1b) and resolves it through effective_formula(caster_level)
at cast time, so the flat bonus grows with the caster and stops at the
stated cap. The engine passes the caster's level into _apply_heal_effect
(required keyword: no silent level-0 under-heal); Maximize Spell
maximizes the scaled formula. Cure Light/Moderate Wounds ship the SRD
values; inflict_light_wounds already carried its scaling_flat.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-13 17:19:58 +02:00
parent a2a80b8690
commit b84238792c
9 changed files with 290 additions and 18 deletions
+11 -7
View File
@@ -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
+3 -2
View File
@@ -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)."
}
+3 -2
View File
@@ -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)."
}
+8 -3
View File
@@ -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
+24
View File
@@ -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(
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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
+232
View File
@@ -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
+3 -1
View File
@@ -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()