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
+5 -4
View File
@@ -550,20 +550,21 @@ class TestTouchDeliveryDefense:
def test_magic_missile_still_auto_hits(self) -> None:
# Given: the real Magic Missile spell (no ranged touch requirement)
# When: cast with only a damage roll in the queue
# Then: no attack roll is consumed — the missile always hits.
# When: cast with only damage rolls in the queue (a level-5 caster
# fires the SRD three missiles, each 1d4+1)
# Then: no attack roll is consumed — every missile always hits.
missile = load_spell(SPELLS_DIR / "magic_missile.json")
assert missile.requires_ranged_touch is False
c = _state(_caster(), "players", (0, 0))
f = _state(_foe(melee=False), "monsters", (0, 1))
engine = CombatEngine(
ScriptedRng([3]), # 1d4+1 -> 4, no attack roll before it
ScriptedRng([3, 3, 3]), # 3 missiles, each 1d4+1 -> 4; no attack roll
_grid(),
[c, f],
spell_registry={"Magic Missile": missile},
)
engine._cast_spell(c, f, "Magic Missile")
assert f.hp == 100 - 4
assert f.hp == 100 - 12
assert not any("ranged touch attack" in line for line in engine._transcript)
def test_requires_ranged_touch_parses_from_spell_json(self) -> None: