From a6cc5aef36992bd783e606cee1614534625f807c Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 10:48:09 +0200 Subject: [PATCH] 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 --- data/spells/burning_hands.json | 7 +- data/spells/fireball.json | 7 +- data/spells/inflict_light_wounds.json | 7 +- data/spells/lightning_bolt.json | 7 +- data/spells/shocking_grasp.json | 7 +- src/pf1e_simulator/combat.py | 8 +- src/pf1e_simulator/spells.py | 108 ++++++++++- tests/test_class_abilities.py | 11 +- tests/test_damage_scaling.py | 264 ++++++++++++++++++++++++++ tests/test_hex_aoe_dr.py | 10 +- tests/test_spells.py | 37 ++-- 11 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 tests/test_damage_scaling.py diff --git a/data/spells/burning_hands.json b/data/spells/burning_hands.json index 1cd0778..1cd783a 100644 --- a/data/spells/burning_hands.json +++ b/data/spells/burning_hands.json @@ -9,10 +9,11 @@ "effects": [ { "type": "damage", - "formula": "1d4", + "formula": "0", "types": ["fire"], - "half_on_save": true + "half_on_save": true, + "scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 } } ], - "description": "SRD spells/burning-hands.md: 1d4 points of fire damage per caster level (maximum 5d4) in a 15-ft. cone; Reflex half. The engine has no caster-level damage scaling (fixed 1d4) and no cone template (single-target damage with Reflex for half)." + "description": "SRD spells/burning-hands.md: 1d4 points of fire damage per caster level (maximum 5d4) in a 15-ft. cone; Reflex half. The cone template is applied in the area-shapes batch; until then the spell resolves on the target." } diff --git a/data/spells/fireball.json b/data/spells/fireball.json index 3f368e0..72116d3 100644 --- a/data/spells/fireball.json +++ b/data/spells/fireball.json @@ -14,12 +14,13 @@ "effects": [ { "type": "damage", - "formula": "6d6", + "formula": "0", "types": ["fire"], - "half_on_save": true + "half_on_save": true, + "scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 } } ] } ], - "description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half." + "description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 1d6 points of fire damage per caster level (maximum 10d6). Reflex save for half." } diff --git a/data/spells/inflict_light_wounds.json b/data/spells/inflict_light_wounds.json index 8535ba6..b223548 100644 --- a/data/spells/inflict_light_wounds.json +++ b/data/spells/inflict_light_wounds.json @@ -9,10 +9,11 @@ "effects": [ { "type": "damage", - "formula": "1d8+1", + "formula": "1d8", "types": ["negative"], - "half_on_save": true + "half_on_save": true, + "scaling_flat": { "amount": 1, "per_levels": 1, "max": 5 } } ], - "description": "Touching a creature channels negative energy, dealing 1d8 points of damage + 1 per caster level (maximum +5); at caster level 1 this is modeled as 1d8+1. Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets." + "description": "SRD spells/inflict-light-wounds.md: touching a creature channels negative energy, dealing 1d8 points of damage + 1 point per caster level (maximum +5). Will half. Undead are instead cured of a like amount of damage - the engine only models the damaging side against living targets." } diff --git a/data/spells/lightning_bolt.json b/data/spells/lightning_bolt.json index be7853c..433cc85 100644 --- a/data/spells/lightning_bolt.json +++ b/data/spells/lightning_bolt.json @@ -9,10 +9,11 @@ "effects": [ { "type": "damage", - "formula": "6d6", + "formula": "0", "types": ["electricity"], - "half_on_save": true + "half_on_save": true, + "scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 } } ], - "description": "You release a powerful stroke of electrical energy, dealing 6d6 points of electricity damage. Reflex save for half." + "description": "SRD spells/lightning-bolt.md: you release a powerful stroke of electrical energy dealing 1d6 points of electricity damage per caster level (maximum 10d6). Reflex save for half. The 120-ft line template is applied in the area-shapes batch; until then the spell resolves on the target." } diff --git a/data/spells/shocking_grasp.json b/data/spells/shocking_grasp.json index 799440c..5328b91 100644 --- a/data/spells/shocking_grasp.json +++ b/data/spells/shocking_grasp.json @@ -9,10 +9,11 @@ "effects": [ { "type": "damage", - "formula": "1d6", + "formula": "0", "types": ["electricity"], - "half_on_save": false + "half_on_save": false, + "scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 5 } } ], - "description": "Your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6) — modeled at caster level 1 (1d6). No save; the +3 attack bonus against metal-armored foes is not modeled." + "description": "SRD spells/shocking-grasp.md: your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6). No save; the +3 attack bonus against metal-armored foes is not modeled." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index bafdb62..480243c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -5783,9 +5783,13 @@ class CombatEngine: caster: CombatantState, crit: bool = False, ) -> str: - raw = effect.formula.roll(self._rng) + # PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved + # against the caster's level at cast time; the scaled formula then + # flows through crits, energy resistance and metamagic unchanged. + formula = effect.effective_formula(caster.combatant.level) + raw = formula.roll(self._rng) if maximize: - raw = effect.formula.max_value() + raw = formula.max_value() if crit: # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that # deals damage threatens on a natural 20 and deals double damage diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index e686aab..1679498 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Literal, TypeGuard, get_args -from pf1e_simulator.dice import DiceExpr, parse_dice +from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice from pf1e_simulator.effects import BonusType, StatModifier, StatTarget if TYPE_CHECKING: @@ -37,6 +37,32 @@ AreaShape = Literal["burst", "cone", "line"] CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"] +@dataclass(frozen=True) +class DamageDieScaling: + """Per-caster-level extra damage dice (PF1e: "1d6 per caster level, max 10d6"). + + - dice: the dice added per scaling step (e.g. 1d6) + - per_levels: caster levels required per added die + - max_dice: cap on the total dice added by the scaling + """ + dice: DiceExpr + per_levels: int + max_dice: int + + +@dataclass(frozen=True) +class DamageFlatScaling: + """Per-caster-level flat damage bonus (PF1e: "+1 per caster level, max +5"). + + - amount: flat points added per scaling step + - per_levels: caster levels required per added step + - max_amount: cap on the total flat bonus from scaling (JSON key "max") + """ + amount: int + per_levels: int + max_amount: int + + @dataclass(frozen=True) class DamageEffect: formula: DiceExpr @@ -48,6 +74,37 @@ class DamageEffect: # no effect on this damage at all (SRD Sound Burst: "Fortitude partial" # negates only the stun — every creature takes the full dice regardless). save_negates: bool = True + # PF1e caster-level scaling (CRB Magic — Caster Level; e.g. Fireball + # "1d6 points of fire damage per caster level (maximum 10d6)"). Absent = + # level-independent damage, the pre-S1b behaviour. + scaling: DamageDieScaling | None = None + scaling_flat: DamageFlatScaling | None = None + + def effective_formula(self, caster_level: int) -> DiceExpr: + """The damage formula resolved for a caster of ``caster_level``. + + PF1e (CRB, Magic — Caster Level): spells whose damage is "per caster + level" grow with the caster and stop at the stated cap. Extra dice = + min(max_dice, level // per_levels); flat bonus = min(max, + (level // per_levels) * amount). The loader guarantees the scaling + dice share the base formula's die type (or the base is flat). + """ + count = self.formula.count + sides = self.formula.sides + bonus = self.formula.bonus + if self.scaling is not None: + extra = min( + self.scaling.max_dice, caster_level // self.scaling.per_levels + ) * self.scaling.dice.count + if count == 0 and extra > 0: + sides = self.scaling.dice.sides + count += extra + 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=count, sides=sides, bonus=bonus) @dataclass(frozen=True) @@ -270,15 +327,62 @@ def _parse_modifier(value: Json) -> StatModifier: ) +def _parse_die_scaling(value: Json) -> DamageDieScaling: + data = _as_dict(value) + dice = parse_dice(_as_str(data["dice"])) + per_levels = _as_int(data.get("per_levels", 1)) + if per_levels < 1: + msg = f"scaling per_levels must be >= 1, got {per_levels}" + raise ValueError(msg) + max_dice = _as_int(data["max_dice"]) + if max_dice < 0: + msg = f"scaling max_dice must be >= 0, got {max_dice}" + raise ValueError(msg) + return DamageDieScaling(dice=dice, per_levels=per_levels, max_dice=max_dice) + + +def _parse_flat_scaling(value: Json) -> DamageFlatScaling: + data = _as_dict(value) + amount = _as_int(data.get("amount", 1)) + per_levels = _as_int(data.get("per_levels", 1)) + if per_levels < 1: + msg = f"scaling_flat per_levels must be >= 1, got {per_levels}" + raise ValueError(msg) + max_amount = _as_int(data["max"]) + if max_amount < 0: + msg = f"scaling_flat max must be >= 0, got {max_amount}" + raise ValueError(msg) + return DamageFlatScaling( + amount=amount, per_levels=per_levels, max_amount=max_amount + ) + + def _parse_effect(data: Json) -> SpellEffect: effect = _as_dict(data) effect_type = _as_str(effect["type"]) if effect_type == "damage": + formula = parse_dice(_as_str(effect["formula"])) + scaling_raw = effect.get("scaling") + scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw) + if ( + scaling is not None + and formula.count > 0 + and scaling.dice.sides != formula.sides + ): + msg = ( + "damage scaling dice must match the base formula die type: " + f"{dice_to_str(scaling.dice)} vs {dice_to_str(formula)}" + ) + raise ValueError(msg) + flat_raw = effect.get("scaling_flat") + scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) return DamageEffect( - formula=parse_dice(_as_str(effect["formula"])), + formula=formula, types=tuple(_as_str(t) for t in _as_list(effect["types"])), half_on_save=bool(effect.get("half_on_save", False)), save_negates=bool(effect.get("save_negates", True)), + scaling=scaling, + scaling_flat=scaling_flat, ) if effect_type == "condition": return ConditionEffect( diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 4cd7a1f..ee7af6c 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -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]: diff --git a/tests/test_damage_scaling.py b/tests/test_damage_scaling.py new file mode 100644 index 0000000..400bd30 --- /dev/null +++ b/tests/test_damage_scaling.py @@ -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 diff --git a/tests/test_hex_aoe_dr.py b/tests/test_hex_aoe_dr.py index 94f6388..06034ff 100644 --- a/tests/test_hex_aoe_dr.py +++ b/tests/test_hex_aoe_dr.py @@ -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 diff --git a/tests/test_spells.py b/tests/test_spells.py index cd92551..c4d2fac 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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: