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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user