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
+63
View File
@@ -63,6 +63,36 @@ class DamageFlatScaling:
max_amount: int
@dataclass(frozen=True)
class ProjectileScaling:
"""Multi-projectile count scaling with caster level.
PF1e (SRD spells/magic-missile.md): "one missile, plus one additional
missile per two caster levels beyond 1st [...] maximum of five missiles";
(SRD spells/scorching-ray.md): "one ray, plus one additional ray for every
four levels beyond 3rd (to a maximum of three rays at 11th level)".
- base: projectiles at the reference level (usually 1)
- at_level: caster level from which extra projectiles begin counting
- per_levels: caster levels required per additional projectile
- max_projectiles: cap (JSON key "max")
"""
base: int
at_level: int
per_levels: int
max_projectiles: int
def count_for(self, caster_level: int) -> int:
"""Projectiles fired by a caster of ``caster_level``.
``base + (caster_level - at_level) // per_levels``, the level delta
floored at 0 (a caster below the reference level still fires the base
projectiles), capped at ``max_projectiles``.
"""
extra = max(0, caster_level - self.at_level) // self.per_levels
return min(self.max_projectiles, self.base + extra)
@dataclass(frozen=True)
class DamageEffect:
formula: DiceExpr
@@ -79,6 +109,10 @@ class DamageEffect:
# level-independent damage, the pre-S1b behaviour.
scaling: DamageDieScaling | None = None
scaling_flat: DamageFlatScaling | None = None
# PF1e multi-projectile spells (Magic Missile, Scorching Ray): the effect
# fires `count_for(caster_level)` projectiles, each rolling its own damage
# and (for a ray) its own attack. Absent = a single projectile.
projectiles: ProjectileScaling | None = None
def effective_formula(self, caster_level: int) -> DiceExpr:
"""The damage formula resolved for a caster of ``caster_level``.
@@ -357,6 +391,32 @@ def _parse_flat_scaling(value: Json) -> DamageFlatScaling:
)
def _parse_projectiles(value: Json) -> ProjectileScaling:
data = _as_dict(value)
base = _as_int(data.get("base", 1))
at_level = _as_int(data.get("at_level", 1))
per_levels = _as_int(data["per_levels"])
if base < 1:
msg = f"projectiles base must be >= 1, got {base}"
raise ValueError(msg)
if per_levels < 1:
msg = f"projectiles per_levels must be >= 1, got {per_levels}"
raise ValueError(msg)
if at_level < 1:
msg = f"projectiles at_level must be >= 1, got {at_level}"
raise ValueError(msg)
max_projectiles = _as_int(data["max"])
if max_projectiles < base:
msg = f"projectiles max must be >= base, got {max_projectiles} < {base}"
raise ValueError(msg)
return ProjectileScaling(
base=base,
at_level=at_level,
per_levels=per_levels,
max_projectiles=max_projectiles,
)
def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data)
effect_type = _as_str(effect["type"])
@@ -376,6 +436,8 @@ def _parse_effect(data: Json) -> SpellEffect:
raise ValueError(msg)
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
proj_raw = effect.get("projectiles")
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
return DamageEffect(
formula=formula,
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
@@ -383,6 +445,7 @@ def _parse_effect(data: Json) -> SpellEffect:
save_negates=bool(effect.get("save_negates", True)),
scaling=scaling,
scaling_flat=scaling_flat,
projectiles=projectiles,
)
if effect_type == "condition":
return ConditionEffect(