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