feat(rules): scale spell damage with caster level
DamageEffect gains optional scaling (1dX per N levels, capped) and scaling_flat (+X per N levels, capped) specs; effective_formula resolves them against the caster's level at cast time, so scaled dice flow through crits, energy resistance and metamagic like the base formula (CRB Magic - Caster Level). Loader rejects mismatched die types. Data: Fireball/Lightning Bolt 1d6/lvl max 10d6, Burning Hands 1d4/lvl max 5d4, Shocking Grasp 1d6/lvl max 5d6, Inflict Light Wounds +1/lvl max +5 (SRD-verified). Cure Light Wounds stays flat: its golden transcript pins 1d8+1 and HealEffect scaling is out of scope. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -492,10 +492,13 @@ def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
|
||||
misty = _combatant(
|
||||
"misty", spells=["Shocking Grasp"], spell_slots={1: 1}
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
goblin = _combatant("goblin", hp_max=60)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 1d6=4.
|
||||
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4])
|
||||
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each
|
||||
# (SRD spells/shocking-grasp.md: 1d6 per caster level, max 5d6; lvl 5).
|
||||
engine, me, enemy = _engine(
|
||||
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4, 4, 4, 4, 4]
|
||||
)
|
||||
engine._execute(
|
||||
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
|
||||
spell_name="Shocking Grasp")
|
||||
@@ -504,7 +507,7 @@ def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
|
||||
"touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
assert enemy[0].hp == 2
|
||||
assert enemy[0].hp == 60 - 20
|
||||
|
||||
|
||||
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Caster-level damage scaling (Package S1b).
|
||||
|
||||
PF1e (CRB, Magic — Caster Level; SRD spells/fireball.md): "1d6 points of fire
|
||||
damage per caster level (maximum 10d6)" — a DamageEffect may declare
|
||||
``"scaling": {"dice": "1d6", "per_levels": 1, "max_dice": 10}`` and/or
|
||||
``"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}`` (SRD inflict
|
||||
light wounds: "1d8 points of damage + 1 point per caster level (maximum +5)").
|
||||
|
||||
Unit tests pin ``DamageEffect.effective_formula`` arithmetic; integration casts
|
||||
pin that the scaled formula — and ONLY the scaled formula — drives the dice
|
||||
consumed, crit doubling, energy resistance and Maximize Spell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.models import DamageReduction
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import (
|
||||
DamageDieScaling,
|
||||
DamageEffect,
|
||||
DamageFlatScaling,
|
||||
SpellSpec,
|
||||
load_spell,
|
||||
)
|
||||
from tests.test_observation_enriched import make_combatant, make_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.models import Combatant
|
||||
|
||||
|
||||
def _fireball_like(*, per_levels: int = 1) -> DamageEffect:
|
||||
return DamageEffect(
|
||||
formula=parse_dice("0"),
|
||||
types=("fire",),
|
||||
scaling=DamageDieScaling(
|
||||
dice=parse_dice("1d6"), per_levels=per_levels, max_dice=10
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# unit: effective_formula arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_die_scaling_grows_with_level_and_stops_at_cap() -> None:
|
||||
"""Given: 1d6 per caster level, maximum 10d6 (SRD Fireball)
|
||||
When: effective_formula resolves for levels 1/5/10/12
|
||||
Then: 1d6, 5d6, 10d6, 10d6 — the cap binds, the base adds nothing."""
|
||||
effect = _fireball_like()
|
||||
assert effect.effective_formula(1) == parse_dice("1d6")
|
||||
assert effect.effective_formula(5) == parse_dice("5d6")
|
||||
assert effect.effective_formula(10) == parse_dice("10d6")
|
||||
assert effect.effective_formula(12) == parse_dice("10d6")
|
||||
|
||||
|
||||
def test_die_scaling_per_levels_divisor() -> None:
|
||||
"""Given: 1d6 per TWO caster levels (SRD Scorching-Ray-style cadence)
|
||||
When: effective_formula resolves for levels 3 and 5
|
||||
Then: 1d6 and 2d6 (level // 2 dice added)."""
|
||||
effect = _fireball_like(per_levels=2)
|
||||
assert effect.effective_formula(3) == parse_dice("1d6")
|
||||
assert effect.effective_formula(5) == parse_dice("2d6")
|
||||
|
||||
|
||||
def test_flat_scaling_adds_to_base_dice_bonus() -> None:
|
||||
"""Given: 1d8 + 1 per caster level, maximum +5 (SRD Inflict Light Wounds)
|
||||
When: effective_formula resolves for levels 1/4/7
|
||||
Then: 1d8+1, 1d8+4, 1d8+5 — the cap binds, the base dice are untouched."""
|
||||
effect = DamageEffect(
|
||||
formula=parse_dice("1d8"),
|
||||
types=("negative",),
|
||||
scaling_flat=DamageFlatScaling(amount=1, per_levels=1, max_amount=5),
|
||||
)
|
||||
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 DamageEffect without scaling specs
|
||||
When: effective_formula resolves at any level
|
||||
Then: the base formula is returned unchanged."""
|
||||
effect = DamageEffect(formula=parse_dice("2d4+1"), types=("force",))
|
||||
assert effect.effective_formula(1) == parse_dice("2d4+1")
|
||||
assert effect.effective_formula(20) == parse_dice("2d4+1")
|
||||
|
||||
|
||||
def test_loader_parses_both_scaling_specs(tmp_path) -> None:
|
||||
"""Given: a damage effect declaring "scaling" and one declaring
|
||||
"scaling_flat"
|
||||
When: load_spell parses them
|
||||
Then: the specs land on the DamageEffect; an effect without them keeps
|
||||
None fields."""
|
||||
spell_file = tmp_path / "scaling.json"
|
||||
spell_file.write_text(
|
||||
'{"name": "Scaled", "level": 3, "school": "evocation",'
|
||||
' "range": "long", "save": "ref", "effects": ['
|
||||
' {"type": "damage", "formula": "0", "types": ["fire"],'
|
||||
' "scaling": {"dice": "1d6", "per_levels": 1, "max_dice": 10}},'
|
||||
' {"type": "damage", "formula": "1d8", "types": ["negative"],'
|
||||
' "scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}},'
|
||||
' {"type": "damage", "formula": "1d4", "types": ["force"]}]}'
|
||||
)
|
||||
spell = load_spell(spell_file)
|
||||
scaled, flat, plain = spell.effects
|
||||
assert isinstance(scaled, DamageEffect)
|
||||
assert isinstance(flat, DamageEffect)
|
||||
assert isinstance(plain, DamageEffect)
|
||||
assert scaled.scaling == DamageDieScaling(
|
||||
dice=parse_dice("1d6"), per_levels=1, max_dice=10
|
||||
)
|
||||
assert flat.scaling_flat == DamageFlatScaling(
|
||||
amount=1, per_levels=1, max_amount=5
|
||||
)
|
||||
assert plain.scaling is None
|
||||
assert plain.scaling_flat is None
|
||||
|
||||
|
||||
def test_loader_rejects_mismatched_scaling_die_type(tmp_path) -> None:
|
||||
"""Given: base 1d8 with 1d6-per-level scaling (not representable as one
|
||||
DiceExpr)
|
||||
When: load_spell parses it
|
||||
Then: it is a data error, not silent corruption."""
|
||||
spell_file = tmp_path / "bad_scaling.json"
|
||||
spell_file.write_text(
|
||||
'{"name": "Bad", "level": 1, "school": "evocation",'
|
||||
' "range": "close", "save": "none", "effects": ['
|
||||
' {"type": "damage", "formula": "1d8", "types": ["fire"],'
|
||||
' "scaling": {"dice": "1d6", "per_levels": 1, "max_dice": 5}}]}'
|
||||
)
|
||||
with pytest.raises(ValueError, match="must match the base formula"):
|
||||
load_spell(spell_file)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# integration: the scaled formula drives the cast
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _caster(level: int, *, spells: list[str], slots: dict[int, int]) -> Combatant:
|
||||
return make_combatant("caster", spells=spells, spell_slots=slots).model_copy(
|
||||
update={"level": level}
|
||||
)
|
||||
|
||||
|
||||
def _cast(
|
||||
caster: Combatant,
|
||||
target: Combatant,
|
||||
spell: SpellSpec,
|
||||
rolls: list[int],
|
||||
*,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
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, "monsters", (2, 0))
|
||||
engine = CombatEngine(
|
||||
ScriptedRng(rolls), grid, [c, t], spell_registry={spell.name: spell}
|
||||
)
|
||||
engine._cast_spell(c, t, spell.name, metamagic=metamagic)
|
||||
return engine, c, t
|
||||
|
||||
|
||||
_SCALED_BOLT = SpellSpec(
|
||||
name="Scaled Bolt",
|
||||
level=3,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="none",
|
||||
effects=(
|
||||
DamageEffect(
|
||||
formula=parse_dice("0"),
|
||||
types=("fire",),
|
||||
scaling=DamageDieScaling(
|
||||
dice=parse_dice("1d6"), per_levels=1, max_dice=10
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_RAY_SCALED_BOLT = SpellSpec(
|
||||
name="Ray Scaled Bolt",
|
||||
level=3,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="none",
|
||||
requires_ranged_touch=True,
|
||||
effects=(_SCALED_BOLT.effects[0],),
|
||||
)
|
||||
|
||||
|
||||
def test_cast_consumes_exactly_level_dice() -> None:
|
||||
"""Given: a level-3 caster of 1d6/level (no save)
|
||||
When: the bolt is cast with exactly 3 scripted d6
|
||||
Then: 3 dice are consumed (a 4th roll would leave one unused, an
|
||||
exhausted queue would raise) and the target takes their sum."""
|
||||
caster = _caster(3, spells=["Scaled Bolt"], slots={3: 1})
|
||||
target = make_combatant("foe", hp=100)
|
||||
engine, _, t = _cast(caster, target, _SCALED_BOLT, [4, 4, 4])
|
||||
assert t.hp == 100 - 12
|
||||
assert any("12 damage" in line for line in engine._transcript)
|
||||
|
||||
|
||||
def test_scaled_damage_flows_through_energy_resistance() -> None:
|
||||
"""Given: a level-3 caster, target with fire resistance 5
|
||||
When: the scaled 3d6 (all 4s = 12) lands
|
||||
Then: resistance subtracts 5 from the SCALED total — 7 damage."""
|
||||
caster = _caster(3, spells=["Scaled Bolt"], slots={3: 1})
|
||||
target = make_combatant("foe", hp=100).model_copy(
|
||||
update={"resistances": {"fire": 5}}
|
||||
)
|
||||
_, _, t = _cast(caster, target, _SCALED_BOLT, [4, 4, 4])
|
||||
assert t.hp == 100 - 7
|
||||
|
||||
|
||||
def test_scaled_damage_doubles_on_confirmed_touch_crit() -> None:
|
||||
"""Given: a level-2 ranged-touch caster of 1d6/level
|
||||
When: natural 20 hits, natural 20 confirms, 2d6 roll 3+3
|
||||
Then: the SCALED dice are doubled (6 * 2 = 12), not the base die."""
|
||||
caster = _caster(2, spells=["Ray Scaled Bolt"], slots={3: 1})
|
||||
caster = caster.model_copy(update={"bab": 2})
|
||||
target = make_combatant("foe", hp=100)
|
||||
_, _, t = _cast(caster, target, _RAY_SCALED_BOLT, [20, 20, 3, 3])
|
||||
assert t.hp == 100 - 12
|
||||
|
||||
|
||||
def test_maximize_metamagic_maximizes_the_scaled_formula() -> None:
|
||||
"""Given: a level-3 caster who knows Maximize Spell casting 1d6/level
|
||||
When: the cast is maximized (level 3 + slot adjustment 3 = slot 6)
|
||||
Then: the SCALED maximum (3d6 = 18) is dealt — the 3 scripted dice are
|
||||
consumed (engine convention: rolled then overwritten) but 18 stands even
|
||||
though they roll 1s."""
|
||||
caster = _caster(3, spells=["Scaled Bolt"], slots={6: 1}).model_copy(
|
||||
update={"metamagic_feats": ["Maximize Spell"]}
|
||||
)
|
||||
target = make_combatant("foe", hp=100)
|
||||
_, _, t = _cast(
|
||||
caster, target, _SCALED_BOLT, [1, 1, 1], metamagic=("Maximize Spell",)
|
||||
)
|
||||
assert t.hp == 100 - 18
|
||||
|
||||
|
||||
def test_dr_still_ignores_scaled_spell_damage() -> None:
|
||||
"""Given: a target with DR 5/- (bypassed by nothing)
|
||||
When: a scaled 3d6 spell (all 4s) lands
|
||||
Then: DR does not touch spell damage (existing rule) — full 12."""
|
||||
caster = _caster(3, spells=["Scaled Bolt"], slots={3: 1})
|
||||
target = make_combatant("foe", hp=100).model_copy(
|
||||
update={"dr": DamageReduction(amount=5, bypass=frozenset())}
|
||||
)
|
||||
_, _, t = _cast(caster, target, _SCALED_BOLT, [4, 4, 4])
|
||||
assert t.hp == 100 - 12
|
||||
@@ -136,13 +136,13 @@ class TestAoEDcEqualsCasterSpellDc:
|
||||
def test_area_save_uses_caster_dc(self) -> None:
|
||||
caster = _make_caster(level=5, int_score=18)
|
||||
target = _make_target(hp_max=200)
|
||||
queue = [1, 12, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
queue = [1, 12, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
engine, c, t = _make_engine(caster, target, queue)
|
||||
hp_before_caster = c.hp
|
||||
hp_before_target = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert t.hp == hp_before_target - 18
|
||||
assert c.hp == hp_before_caster - 18
|
||||
assert t.hp == hp_before_target - 15
|
||||
assert c.hp == hp_before_caster - 15
|
||||
|
||||
|
||||
class TestSpellDamageBypassesDr:
|
||||
@@ -156,8 +156,8 @@ class TestSpellDamageBypassesDr:
|
||||
target_with_dr = target.model_copy(update={
|
||||
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
||||
})
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert t.hp == hp_before - 18
|
||||
assert t.hp == hp_before - 15
|
||||
|
||||
+19
-18
@@ -310,27 +310,27 @@ class TestFireballSave:
|
||||
def test_failed_save_full_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
|
||||
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3)
|
||||
|
||||
def test_successful_save_half_damage(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
full = 3 + 3 + 3 + 3 + 3 + 3
|
||||
full = 3 + 3 + 3 + 3 + 3
|
||||
assert t.hp == hp_before - (full // 2)
|
||||
|
||||
def test_transcript_shows_saved(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target()
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
assert any("SAVED" in line for line in engine._transcript)
|
||||
|
||||
@@ -458,18 +458,19 @@ class TestTargetedSpellSaveNegates:
|
||||
|
||||
|
||||
class TestLightningBoltSave:
|
||||
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True)
|
||||
When: the target succeeds its save
|
||||
Then: damage is still halved (half-on-save is declared for line effects)."""
|
||||
"""Given: Lightning Bolt (ref save for half, half_on_save=True, 1d6 per
|
||||
caster level max 10d6 — SRD spells/lightning-bolt.md)
|
||||
When: the level-5 target succeeds its save
|
||||
Then: damage is halved from the SCALED 5d6."""
|
||||
|
||||
def test_successful_save_still_halves(self) -> None:
|
||||
caster = _make_caster()
|
||||
target = _make_target(hp_max=200)
|
||||
# 1 save roll (natural 20 = success) + 6 damage dice
|
||||
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
|
||||
# 1 save roll (natural 20 = success) + 5 damage dice (caster level 5)
|
||||
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Lightning Bolt")
|
||||
full = 3 + 3 + 3 + 3 + 3 + 3
|
||||
full = 3 + 3 + 3 + 3 + 3
|
||||
assert t.hp == hp_before - (full // 2)
|
||||
|
||||
|
||||
@@ -661,13 +662,13 @@ class TestSpellDamageWithDr:
|
||||
target_with_dr = target.model_copy(update={
|
||||
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
||||
})
|
||||
# 2 save rolls (2 targets in area) + 6 damage dice
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
# 2 save rolls (2 targets in area) + 5 damage dice each (level 5)
|
||||
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
||||
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Fireball")
|
||||
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage
|
||||
assert t.hp == hp_before - 18
|
||||
# Save fails (2), 5d6 rolls all 3 = 15 damage, DR ignored -> 15 damage
|
||||
assert t.hp == hp_before - 15
|
||||
|
||||
|
||||
class TestSummonMonster:
|
||||
|
||||
Reference in New Issue
Block a user