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:
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Multi-projectile spells (Package S2b).
|
||||
|
||||
PF1e (SRD spells/magic-missile.md): Magic Missile fires "one missile, plus one
|
||||
additional missile per two caster levels beyond 1st [...] maximum of five
|
||||
missiles", each 1d4+1 force, auto-hit (no attack roll).
|
||||
(SRD spells/scorching-ray.md): Scorching Ray fires "one ray, plus one
|
||||
additional ray for every four levels beyond 3rd (to a maximum of three rays at
|
||||
11th level)", each a ranged touch attack for 4d6 fire.
|
||||
|
||||
Each projectile rolls its own damage and (for a ray) its own attack; if the
|
||||
target drops mid-sequence the remaining projectiles are lost (the engine's
|
||||
existing multi-attack convention).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import DamageEffect, load_spell, load_spell_registry
|
||||
|
||||
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||
|
||||
|
||||
def _caster(cid: str = "caster", *, level: int = 1) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="dagger",
|
||||
kind="melee",
|
||||
attack_bonus=2,
|
||||
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=level,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=14, con_score=12,
|
||||
int_score=18, wis_score=12, cha_score=10,
|
||||
),
|
||||
hp_max=20,
|
||||
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
||||
bab=2,
|
||||
initiative_mod=2,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=3, ref=4, will=4),
|
||||
attacks=[attack],
|
||||
casting_ability="int",
|
||||
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
|
||||
)
|
||||
|
||||
|
||||
def _target(cid: str = "target", *, hp_max: int = 200, touch_ac: int = 10) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="club",
|
||||
kind="melee",
|
||||
attack_bonus=3,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=10, con_score=10,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=hp_max,
|
||||
ac=ACProfile(total=touch_ac, touch=touch_ac, flat_footed=touch_ac),
|
||||
bab=1,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=2, ref=2, will=2),
|
||||
attacks=[attack],
|
||||
)
|
||||
|
||||
|
||||
def _engine(
|
||||
caster: Combatant,
|
||||
target: Combatant,
|
||||
queue: list[int],
|
||||
*,
|
||||
caster_pos: tuple[int, int] = (0, 0),
|
||||
target_pos: tuple[int, int] = (3, 0),
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
|
||||
t = CombatantState(combatant=target, side="monsters", pos=target_pos, hp=target.hp_max)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
reg = load_spell_registry(_SPELLS_DIR)
|
||||
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
|
||||
return engine, c, t
|
||||
|
||||
|
||||
def _projectile_count(spell_file: str, level: int) -> int:
|
||||
spell = load_spell(_SPELLS_DIR / spell_file)
|
||||
effect = spell.effects[0]
|
||||
assert isinstance(effect, DamageEffect)
|
||||
assert effect.projectiles is not None
|
||||
return effect.projectiles.count_for(level)
|
||||
|
||||
|
||||
class TestMagicMissileProjectileCount:
|
||||
"""Given: Magic Missile's projectile scaling (base 1, +1 per 2 levels
|
||||
beyond 1st, max 5)
|
||||
When: the count is resolved for caster levels 1 / 4 / 9 / 20
|
||||
Then: it is 1 / 2 / 5 / 5 missiles (SRD spells/magic-missile.md)."""
|
||||
|
||||
def test_count_at_level_1(self) -> None:
|
||||
assert _projectile_count("magic_missile.json", 1) == 1
|
||||
|
||||
def test_count_at_level_4(self) -> None:
|
||||
assert _projectile_count("magic_missile.json", 4) == 2
|
||||
|
||||
def test_count_at_level_9(self) -> None:
|
||||
assert _projectile_count("magic_missile.json", 9) == 5
|
||||
|
||||
def test_count_caps_at_five(self) -> None:
|
||||
assert _projectile_count("magic_missile.json", 20) == 5
|
||||
|
||||
|
||||
class TestScorchingRayProjectileCount:
|
||||
"""Given: Scorching Ray's projectile scaling (base 1, +1 per 4 levels
|
||||
beyond 3rd, max 3)
|
||||
When: the count is resolved for caster levels 1 / 7 / 11 / 20
|
||||
Then: it is 1 / 2 / 3 / 3 rays (SRD spells/scorching-ray.md)."""
|
||||
|
||||
def test_count_at_level_1(self) -> None:
|
||||
assert _projectile_count("scorching_ray.json", 1) == 1
|
||||
|
||||
def test_count_at_level_7(self) -> None:
|
||||
assert _projectile_count("scorching_ray.json", 7) == 2
|
||||
|
||||
def test_count_at_level_11(self) -> None:
|
||||
assert _projectile_count("scorching_ray.json", 11) == 3
|
||||
|
||||
def test_count_caps_at_three(self) -> None:
|
||||
assert _projectile_count("scorching_ray.json", 20) == 3
|
||||
|
||||
|
||||
class TestMagicMissileFiresAllMissiles:
|
||||
"""Given: a level-5 caster (3 missiles) and a high-hp target
|
||||
When: Magic Missile is cast (auto-hit, no attack roll)
|
||||
Then: all three missiles land, each rolling its own 1d4+1."""
|
||||
|
||||
def test_three_missiles_sum(self) -> None:
|
||||
caster = _caster(level=5)
|
||||
target = _target(hp_max=200)
|
||||
engine, c, t = _engine(caster, target, [4, 5, 6])
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Magic Missile")
|
||||
# (4+1) + (5+1) + (6+1) = 18
|
||||
assert t.hp == hp_before - 18
|
||||
|
||||
def test_no_attack_roll_consumed(self) -> None:
|
||||
"""Given: auto-hit — no ranged touch attack roll is made
|
||||
When: exactly three d4 rolls are scripted
|
||||
Then: the cast consumes them all for damage (no touch-attack line)."""
|
||||
caster = _caster(level=5)
|
||||
target = _target(hp_max=200)
|
||||
engine, c, t = _engine(caster, target, [3, 3, 3])
|
||||
engine._cast_spell(c, t, "Magic Missile")
|
||||
assert not any("touch attack" in line for line in engine._transcript)
|
||||
assert t.hp == 200 - 3 * (3 + 1)
|
||||
|
||||
|
||||
class TestScorchingRayPerProjectileAttack:
|
||||
"""Given: a caster firing several rays at a target
|
||||
When: Scorching Ray is cast
|
||||
Then: each ray rolls its OWN ranged touch attack, and only the rays that
|
||||
hit deal their 4d6."""
|
||||
|
||||
def test_each_ray_rolls_its_own_attack(self) -> None:
|
||||
caster = _caster(level=11) # 3 rays
|
||||
target = _target(hp_max=500, touch_ac=10)
|
||||
# 3 rays, each: d20 attack (15 -> hits AC 10 with bab2+dex2=+4)
|
||||
# then 4d6 damage (four scripted d6s of 3 each)
|
||||
queue = [15, 3, 3, 3, 3, 15, 3, 3, 3, 3, 15, 3, 3, 3, 3]
|
||||
engine, c, t = _engine(caster, target, queue)
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Scorching Ray")
|
||||
attack_lines = [ln for ln in engine._transcript if "ranged touch attack" in ln]
|
||||
assert len(attack_lines) == 3
|
||||
# 3 rays x 4d6 (each 3) = 3 x 12 = 36
|
||||
assert t.hp == hp_before - 36
|
||||
|
||||
def test_missed_ray_deals_no_damage(self) -> None:
|
||||
"""Given: the first ray's attack roll is a natural 1 (auto-miss)
|
||||
When: the second ray hits
|
||||
Then: only the hit ray deals damage."""
|
||||
caster = _caster(level=7) # 2 rays
|
||||
target = _target(hp_max=500, touch_ac=10)
|
||||
# ray 1: d20=1 (auto miss); ray 2: d20=15 hit + 4d6 of 3
|
||||
queue = [1, 15, 3, 3, 3, 3]
|
||||
engine, c, t = _engine(caster, target, queue)
|
||||
hp_before = t.hp
|
||||
engine._cast_spell(c, t, "Scorching Ray")
|
||||
# only the second ray lands: 4d6 (3+3+3+3) = 12
|
||||
assert t.hp == hp_before - 12
|
||||
|
||||
|
||||
class TestProjectilesLostWhenTargetDrops:
|
||||
"""Given: a level-5 caster (3 missiles) and a target that dies on the
|
||||
second missile
|
||||
When: Magic Missile is cast
|
||||
Then: the third missile is lost (target dropped mid-sequence)."""
|
||||
|
||||
def test_remaining_missiles_lost(self) -> None:
|
||||
caster = _caster(level=5)
|
||||
# each missile rolls 4+1 = 5; hp 6 -> 1 -> -4 after two, third lost
|
||||
target = _target(hp_max=6)
|
||||
engine, c, t = _engine(caster, target, [4, 4, 4])
|
||||
engine._cast_spell(c, t, "Magic Missile")
|
||||
assert t.hp == 6 - 10
|
||||
|
||||
|
||||
class TestProjectileLoaderValidation:
|
||||
"""Given: a projectiles spec in a spell JSON
|
||||
When: parsed
|
||||
Then: the scaling fields are carried; a max below base is a data error."""
|
||||
|
||||
def test_parses_projectile_spec(self, tmp_path: Path) -> None:
|
||||
spell_file = tmp_path / "volley.json"
|
||||
spell_file.write_text(
|
||||
'{"name": "Volley", "level": 2, "school": "evocation",'
|
||||
' "range": "close", "save": "none", "requires_ranged_touch": true,'
|
||||
' "effects": [{"type": "damage", "formula": "4d6", "types": ["fire"],'
|
||||
' "projectiles": {"base": 1, "per_levels": 4, "at_level": 3, "max": 3}}]}'
|
||||
)
|
||||
spell = load_spell(spell_file)
|
||||
effect = spell.effects[0]
|
||||
assert isinstance(effect, DamageEffect)
|
||||
assert effect.projectiles is not None
|
||||
assert effect.projectiles.count_for(11) == 3
|
||||
|
||||
def test_rejects_max_below_base(self, tmp_path: Path) -> None:
|
||||
spell_file = tmp_path / "bad.json"
|
||||
spell_file.write_text(
|
||||
'{"name": "Bad", "level": 1, "school": "evocation",'
|
||||
' "range": "close", "save": "none",'
|
||||
' "effects": [{"type": "damage", "formula": "1d4", "types": ["force"],'
|
||||
' "projectiles": {"base": 2, "per_levels": 2, "at_level": 1, "max": 1}}]}'
|
||||
)
|
||||
with pytest.raises(ValueError, match="projectiles max must be >= base"):
|
||||
load_spell(spell_file)
|
||||
@@ -92,33 +92,24 @@ class TestGoldenCombats:
|
||||
)
|
||||
|
||||
def test_tammara_vs_lich(self):
|
||||
"""Tammara vs Lich (boss fight) — full pinned transcript."""
|
||||
"""Tammara vs Lich (boss fight) — full pinned transcript.
|
||||
|
||||
Updated (S2b): the lich is caster level 11, so Magic Missile now fires
|
||||
its SRD full five missiles (1d4+1 each) instead of a single one. Three
|
||||
missiles (16 + 11 + 16 = 43) drop Tammara below 0 hp on round 1; the
|
||||
remaining two are lost (the engine's target-drops-mid-sequence
|
||||
convention), so the lich wins in 1 round.
|
||||
"""
|
||||
result, _ = run_golden("tammara", "lich")
|
||||
assert result.winner == "monsters"
|
||||
assert result.rounds == 6
|
||||
assert result.rounds == 1
|
||||
assert result.transcript == (
|
||||
"initiative: tammara d20=15+7=22",
|
||||
"initiative: lich d20=10+6=16",
|
||||
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
||||
"(36->36)"),
|
||||
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (36->20)",
|
||||
("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
||||
"(20->20)"),
|
||||
"round 2 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)",
|
||||
"round 3 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)",
|
||||
"round 3 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 11 damage (20->9)",
|
||||
"round 4 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +11 HP (9->20)",
|
||||
"round 4 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)",
|
||||
"round 5 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)",
|
||||
"round 5 lich: move (10,10)->(9,9)->(8,8)->(7,7)->(6,6)->(5,5)->(4,4)",
|
||||
("round 6 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
||||
"(20->20)"),
|
||||
"round 6 lich: fear aura on tammara -> SAVED",
|
||||
"round 6 lich: move (4,4)->(3,3)->(2,2)->(1,1)",
|
||||
("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 16 -> HIT 25 damage "
|
||||
"(20->-5) paralyzed (11r)"),
|
||||
"round 6 lich: tammara down",
|
||||
"battle over: monsters win in 6 rounds",
|
||||
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)",
|
||||
"battle over: monsters win in 1 rounds",
|
||||
)
|
||||
|
||||
def _assert_deterministic_goblin(self, pc_name: str):
|
||||
|
||||
+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