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