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:
@@ -1040,10 +1040,13 @@ Règles modélisées :
|
|||||||
total scalé suit ensuite le critique, la résistance énergétique et la
|
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
|
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
|
scalage de type différent du dé de base). Absence de scalage = comportement
|
||||||
inchangé. Simplification documentée : le scalage ne s'applique qu'aux
|
inchangé. Le scalage s'applique aussi aux soins (S5b) : un `HealEffect`
|
||||||
dégâts (`DamageEffect`) — les soins par niveau (Cure Light Wounds
|
porte le même `scaling_flat` (bonus plat par niveau) résolu par
|
||||||
« +1 per caster level (maximum +5) ») ne sont pas scalés (la fiche dorée
|
`effective_formula(niveau)` à l'incantation — Cure Light Wounds
|
||||||
épingle `1d8+1`).
|
« 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
|
- Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer
|
||||||
`"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le
|
`"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le
|
||||||
nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels`
|
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
|
(rayons), temps d'incantation `cast_time`) et types d'effets
|
||||||
(`DamageEffect`, `ConditionEffect`,
|
(`DamageEffect`, `ConditionEffect`,
|
||||||
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
|
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
|
||||||
`DamageEffect` porte des specs de scalage optionnels (`scaling` en dés,
|
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
|
||||||
`scaling_flat` en bonus plat) résolues par `effective_formula(niveau)` au
|
optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par
|
||||||
moment de l'incantation (S1b). Portées PF1e (personal, touch, close, medium,
|
`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`,
|
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
||||||
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
|
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
|
||||||
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
|
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "heal",
|
"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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "heal",
|
"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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6202,7 +6202,10 @@ class CombatEngine:
|
|||||||
if isinstance(effect, ConditionEffect):
|
if isinstance(effect, ConditionEffect):
|
||||||
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
||||||
if isinstance(effect, HealEffect):
|
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)
|
return self._apply_buff_effect(effect, target, spell_name=spell_name)
|
||||||
|
|
||||||
def _apply_damage_effect(
|
def _apply_damage_effect(
|
||||||
@@ -6386,12 +6389,14 @@ class CombatEngine:
|
|||||||
effect: HealEffect,
|
effect: HealEffect,
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
*,
|
*,
|
||||||
|
caster_level: int,
|
||||||
empower: bool,
|
empower: bool,
|
||||||
maximize: bool,
|
maximize: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
heal = effect.formula.roll(self._rng)
|
formula = effect.effective_formula(caster_level)
|
||||||
|
heal = formula.roll(self._rng)
|
||||||
if maximize:
|
if maximize:
|
||||||
heal = effect.formula.max_value()
|
heal = formula.max_value()
|
||||||
if empower:
|
if empower:
|
||||||
heal = int(heal * 1.5)
|
heal = int(heal * 1.5)
|
||||||
old_hp = target.hp
|
old_hp = target.hp
|
||||||
|
|||||||
@@ -182,6 +182,27 @@ class ConditionEffect:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class HealEffect:
|
class HealEffect:
|
||||||
formula: DiceExpr
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -529,8 +550,11 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
save_partial=save_partial,
|
save_partial=save_partial,
|
||||||
)
|
)
|
||||||
if effect_type == "heal":
|
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(
|
return HealEffect(
|
||||||
formula=parse_dice(_as_str(effect["formula"])),
|
formula=parse_dice(_as_str(effect["formula"])),
|
||||||
|
scaling_flat=scaling_flat,
|
||||||
)
|
)
|
||||||
if effect_type == "buff":
|
if effect_type == "buff":
|
||||||
return BuffEffect(
|
return BuffEffect(
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ def test_heal_spell_clears_bleed() -> None:
|
|||||||
_add_condition(a, "bleeding")
|
_add_condition(a, "bleeding")
|
||||||
a.conditions.append(BLEEDING)
|
a.conditions.append(BLEEDING)
|
||||||
result = eng._apply_heal_effect(
|
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 result == "+5 HP"
|
||||||
assert a.hp == 55
|
assert a.hp == 55
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ def test_heal_spell_stabilizes_dying_creature() -> None:
|
|||||||
s.hp = -3
|
s.hp = -3
|
||||||
eng._rng = ScriptedRng([1]) # 1d8 -> 1: only 1 point cured
|
eng._rng = ScriptedRng([1]) # 1d8 -> 1: only 1 point cured
|
||||||
result = eng._apply_heal_effect(
|
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 result == "+1 HP"
|
||||||
assert s.hp == -2
|
assert s.hp == -2
|
||||||
@@ -251,7 +252,8 @@ def test_heal_to_positive_hp_does_not_stabilize() -> None:
|
|||||||
s.hp = -3
|
s.hp = -3
|
||||||
eng._rng = ScriptedRng([8])
|
eng._rng = ScriptedRng([8])
|
||||||
eng._apply_heal_effect(
|
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.hp == 5
|
||||||
assert s.stabilized_hp is None
|
assert s.stabilized_hp is None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -588,7 +588,9 @@ class TestCureLightWounds:
|
|||||||
target_side="players")
|
target_side="players")
|
||||||
t.hp = 10
|
t.hp = 10
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
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:
|
def test_heal_capped_at_max(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
|
|||||||
Reference in New Issue
Block a user