feat(rules): support multi-projectile spells

SRD Magic Missile fires one missile +1 per two caster levels beyond 1st (max 5, 1d4+1 force, auto-hit); Scorching Ray fires one ray +1 per four levels beyond 3rd (max 3, 4d6 fire, ranged touch each). DamageEffect gains a ProjectileScaling spec (JSON 'projectiles'); _apply_damage_effect loops count_for(caster_level) projectiles, each rolling its own damage and (for a declared-projectile ray) its own ranged touch attack, with the target-drops-mid-sequence convention losing the rest. Updated magic_missile.json/scorching_ray.json, the golden lich transcript (CL 11 now fires 5 missiles, Tammara dies round 1: 43 damage), the scripted MM queues in test_spells/test_conformance_spellflow, added tests/test_multi_projectile.py, and the README.

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 12:05:56 +02:00
parent 642f449b9e
commit 88ef9f39be
9 changed files with 482 additions and 74 deletions
+13 -8
View File
@@ -283,21 +283,23 @@ class TestSpellRangeFt:
class TestMagicMissile:
"""Given: a caster casting Magic Missile (no save, force damage)
When: _cast_spell is called
Then: target takes full damage with no save."""
Then: target takes full damage with no save; the level-5 caster fires the
SRD three missiles (1 + one per two levels beyond 1st), each 1d4+1."""
def test_deals_damage_no_save(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
# 3 missiles (level 5), each rolls 1d4+1 -> three scripted d4s of 3
engine, c, t = _make_engine(caster, target, [3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Magic Missile")
assert t.hp < hp_before
assert t.hp == hp_before - (3 + 1)
assert t.hp == hp_before - 3 * (3 + 1)
def test_transcript_shows_no_save(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [3])
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3, 3, 3])
engine._cast_spell(c, t, "Magic Missile")
assert any("NO SAVE" in line for line in engine._transcript)
@@ -636,7 +638,8 @@ class TestSpellDispatchViaExecute:
def test_execute_cast_spell(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [4])
# level-5 caster fires 3 missiles (1d4+1 each)
engine, c, t = _make_engine(caster, target, [4, 4, 4])
hp_before = t.hp
engine._execute(
c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile")
@@ -710,7 +713,8 @@ class TestSpellSlots:
def test_slot_depense(self) -> None:
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
# level-5 caster fires 3 missiles (1d4+1 each)
engine, c, t = _make_engine(caster, target, [3, 3, 3])
engine._cast_spell(c, t, "Magic Missile") # level 1
assert c.spell_slots_left[1] == 1
assert c.spell_slots_left[2] == 1
@@ -720,7 +724,8 @@ class TestSpellSlots:
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
)
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
# level-5 caster fires 3 missiles (1d4+1 each)
engine, c, t = _make_engine(caster, target, [3, 3, 3])
assert _choose_spell_action(engine, c, t) is not None
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
assert c.spell_slots_left[1] == 0