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