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:
2026-09-13 10:48:09 +02:00
parent 4659fae9f6
commit a6cc5aef36
11 changed files with 427 additions and 46 deletions
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "1d4", "formula": "0",
"types": ["fire"], "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."
} }
+4 -3
View File
@@ -14,12 +14,13 @@
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "6d6", "formula": "0",
"types": ["fire"], "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."
} }
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "1d8+1", "formula": "1d8",
"types": ["negative"], "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."
} }
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "6d6", "formula": "0",
"types": ["electricity"], "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."
} }
+4 -3
View File
@@ -9,10 +9,11 @@
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "1d6", "formula": "0",
"types": ["electricity"], "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."
} }
+6 -2
View File
@@ -5783,9 +5783,13 @@ class CombatEngine:
caster: CombatantState, caster: CombatantState,
crit: bool = False, crit: bool = False,
) -> str: ) -> 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: if maximize:
raw = effect.formula.max_value() raw = formula.max_value()
if crit: if crit:
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
# deals damage threatens on a natural 20 and deals double damage # deals damage threatens on a natural 20 and deals double damage
+106 -2
View File
@@ -19,7 +19,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args 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 from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -37,6 +37,32 @@ AreaShape = Literal["burst", "cone", "line"]
CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"] 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) @dataclass(frozen=True)
class DamageEffect: class DamageEffect:
formula: DiceExpr formula: DiceExpr
@@ -48,6 +74,37 @@ class DamageEffect:
# no effect on this damage at all (SRD Sound Burst: "Fortitude partial" # no effect on this damage at all (SRD Sound Burst: "Fortitude partial"
# negates only the stun — every creature takes the full dice regardless). # negates only the stun — every creature takes the full dice regardless).
save_negates: bool = True 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) @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: def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data) effect = _as_dict(data)
effect_type = _as_str(effect["type"]) effect_type = _as_str(effect["type"])
if effect_type == "damage": 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( return DamageEffect(
formula=parse_dice(_as_str(effect["formula"])), formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])), types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(effect.get("half_on_save", False)), half_on_save=bool(effect.get("half_on_save", False)),
save_negates=bool(effect.get("save_negates", True)), save_negates=bool(effect.get("save_negates", True)),
scaling=scaling,
scaling_flat=scaling_flat,
) )
if effect_type == "condition": if effect_type == "condition":
return ConditionEffect( return ConditionEffect(
+7 -4
View File
@@ -492,10 +492,13 @@ def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
misty = _combatant( misty = _combatant(
"misty", spells=["Shocking Grasp"], spell_slots={1: 1} "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) reg = load_spell_registry(SPELLS_DIR)
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 1d6=4. # Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4]) # (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( engine._execute(
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id, me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
spell_name="Shocking Grasp") 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 "touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
for line in engine._transcript for line in engine._transcript
) )
assert enemy[0].hp == 2 assert enemy[0].hp == 60 - 20
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]: def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
+264
View File
@@ -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
+5 -5
View File
@@ -136,13 +136,13 @@ class TestAoEDcEqualsCasterSpellDc:
def test_area_save_uses_caster_dc(self) -> None: def test_area_save_uses_caster_dc(self) -> None:
caster = _make_caster(level=5, int_score=18) caster = _make_caster(level=5, int_score=18)
target = _make_target(hp_max=200) 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) engine, c, t = _make_engine(caster, target, queue)
hp_before_caster = c.hp hp_before_caster = c.hp
hp_before_target = t.hp hp_before_target = t.hp
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before_target - 18 assert t.hp == hp_before_target - 15
assert c.hp == hp_before_caster - 18 assert c.hp == hp_before_caster - 15
class TestSpellDamageBypassesDr: class TestSpellDamageBypassesDr:
@@ -156,8 +156,8 @@ class TestSpellDamageBypassesDr:
target_with_dr = target.model_copy(update={ target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()), "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) engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - 18 assert t.hp == hp_before - 15
+19 -18
View File
@@ -310,27 +310,27 @@ class TestFireballSave:
def test_failed_save_full_damage(self) -> None: def test_failed_save_full_damage(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=200) target = _make_target(hp_max=200)
# 2 save rolls (2 targets in area) + 6 damage dice # 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, 3, 3]) engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Fireball") 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: def test_successful_save_half_damage(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=200) target = _make_target(hp_max=200)
# 2 save rolls (2 targets in area) + 6 damage dice # 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, 3, 3]) engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Fireball") 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) assert t.hp == hp_before - (full // 2)
def test_transcript_shows_saved(self) -> None: def test_transcript_shows_saved(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target() target = _make_target()
# 2 save rolls (2 targets in area) + 6 damage dice # 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, 3, 3]) 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") engine._cast_spell(c, t, "Fireball")
assert any("SAVED" in line for line in engine._transcript) assert any("SAVED" in line for line in engine._transcript)
@@ -458,18 +458,19 @@ class TestTargetedSpellSaveNegates:
class TestLightningBoltSave: class TestLightningBoltSave:
"""Given: Lightning Bolt (line, ref save for half, half_on_save=True) """Given: Lightning Bolt (ref save for half, half_on_save=True, 1d6 per
When: the target succeeds its save caster level max 10d6 — SRD spells/lightning-bolt.md)
Then: damage is still halved (half-on-save is declared for line effects).""" When: the level-5 target succeeds its save
Then: damage is halved from the SCALED 5d6."""
def test_successful_save_still_halves(self) -> None: def test_successful_save_still_halves(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=200) target = _make_target(hp_max=200)
# 1 save roll (natural 20 = success) + 6 damage dice # 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, 3]) engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3])
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt") 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) assert t.hp == hp_before - (full // 2)
@@ -661,13 +662,13 @@ class TestSpellDamageWithDr:
target_with_dr = target.model_copy(update={ target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset()), "dr": DamageReduction(amount=5, bypass=frozenset()),
}) })
# 2 save rolls (2 targets in area) + 6 damage dice # 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, 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) engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR ignored -> 18 damage # Save fails (2), 5d6 rolls all 3 = 15 damage, DR ignored -> 15 damage
assert t.hp == hp_before - 18 assert t.hp == hp_before - 15
class TestSummonMonster: class TestSummonMonster: