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