Files
pf1e-simulator/tests/test_heal_scaling.py
T
ctan b84238792c 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>
2026-09-13 17:19:58 +02:00

233 lines
8.8 KiB
Python

"""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