"""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)