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
+7 -4
View File
@@ -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]: