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:
@@ -425,6 +425,21 @@ def _spell_half_on_save(spell: SpellSpec) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _spell_declares_projectiles(spell: SpellSpec) -> bool:
|
||||
"""True when a top-level damage effect carries a projectile-scaling spec.
|
||||
|
||||
Such spells (Magic Missile, Scorching Ray) resolve EVERY projectile's
|
||||
attack roll inside ``_apply_damage_effect`` — even a single-ray Scorching
|
||||
Ray — so the one-shot delivery-gate ranged touch attack is skipped for
|
||||
them. Rays without a projectile spec (Acid Arrow, Ray of Frost) keep the
|
||||
single delivery attack unchanged.
|
||||
"""
|
||||
return any(
|
||||
isinstance(e, DamageEffect) and e.projectiles is not None
|
||||
for e in spell.effects
|
||||
)
|
||||
|
||||
|
||||
def _spell_area_dict(spell: SpellSpec) -> dict[str, object] | None:
|
||||
"""Structured area template of a spell, or None if it has no top-level AreaEffect.
|
||||
|
||||
@@ -5322,6 +5337,7 @@ class CombatEngine:
|
||||
self._apply_and_log_cast(
|
||||
caster, target, spell, spell_name, center_pos,
|
||||
metamagic=applied, slot_level=used_level, spell_crit=delivery_crit,
|
||||
attack_penalty=attack_penalty,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -5369,6 +5385,10 @@ class CombatEngine:
|
||||
(the spell's damage dice are then doubled)."""
|
||||
crit = False
|
||||
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
||||
# PF1e multi-projectile rays (Scorching Ray): each projectile rolls its
|
||||
# own ranged touch attack, resolved per-projectile in
|
||||
# _apply_damage_effect, so the single delivery attack is skipped here.
|
||||
per_projectile_attack = _spell_declares_projectiles(spell)
|
||||
if (
|
||||
target is not None
|
||||
and spell.range == "touch"
|
||||
@@ -5384,6 +5404,7 @@ class CombatEngine:
|
||||
target is not None
|
||||
and spell.requires_ranged_touch
|
||||
and not has_area
|
||||
and not per_projectile_attack
|
||||
and target.side != caster.side
|
||||
):
|
||||
hit, ranged_crit = self._spell_ranged_touch_attack_hits(
|
||||
@@ -5609,6 +5630,7 @@ class CombatEngine:
|
||||
metamagic: tuple[str, ...] = (),
|
||||
slot_level: int | None = None,
|
||||
spell_crit: bool = False,
|
||||
attack_penalty: int = 0,
|
||||
) -> None:
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates).
|
||||
|
||||
@@ -5618,6 +5640,8 @@ class CombatEngine:
|
||||
uses the DC of the higher slot level). ``spell_crit`` is a confirmed
|
||||
touch-delivery critical: the spell's damage dice are doubled (CRB
|
||||
"Range — Touch": "deals double damage on a successful critical hit").
|
||||
``attack_penalty`` (Spell Combat) folds into each projectile's ranged
|
||||
touch attack for multi-projectile ray spells.
|
||||
"""
|
||||
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
||||
save_result = (
|
||||
@@ -5633,6 +5657,15 @@ class CombatEngine:
|
||||
)
|
||||
# Apply only the metamagic feats chosen for this cast (B7)
|
||||
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
||||
# PF1e multi-projectile rays (Scorching Ray): the delivery gate skipped
|
||||
# its single ranged touch attack, so each projectile rolls its own here
|
||||
# — only against a hostile target (a friendly ray needs no attack).
|
||||
per_projectile_attack = (
|
||||
spell.requires_ranged_touch
|
||||
and _spell_declares_projectiles(spell)
|
||||
and target is not None
|
||||
and target.side != caster.side
|
||||
)
|
||||
hp_before = target.hp if target is not None else caster.hp
|
||||
parts = self._apply_spell_effects_from_list(
|
||||
metamagic_effects, target, save_result, caster_live, target_live,
|
||||
@@ -5640,7 +5673,8 @@ class CombatEngine:
|
||||
empower="Empower Spell" in metamagic,
|
||||
maximize="Maximize Spell" in metamagic,
|
||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
||||
crit=spell_crit,
|
||||
crit=spell_crit, ranged_touch=per_projectile_attack,
|
||||
attack_penalty=attack_penalty,
|
||||
)
|
||||
save_str = _save_label(save_result)
|
||||
if target is None and center_pos is not None:
|
||||
@@ -5697,6 +5731,8 @@ class CombatEngine:
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
save_type: SaveType = "none",
|
||||
crit: bool = False,
|
||||
ranged_touch: bool = False,
|
||||
attack_penalty: int = 0,
|
||||
) -> list[str]:
|
||||
saved = save_result is not None and save_result.success
|
||||
return [
|
||||
@@ -5705,7 +5741,7 @@ class CombatEngine:
|
||||
caster=caster, spell_name=spell_name,
|
||||
empower=empower, maximize=maximize, saved=saved,
|
||||
dc=dc, center_pos=center_pos, save_type=save_type,
|
||||
crit=crit,
|
||||
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
||||
)
|
||||
for effect in effects
|
||||
]
|
||||
@@ -5726,6 +5762,8 @@ class CombatEngine:
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
save_type: SaveType = "none",
|
||||
crit: bool = False,
|
||||
ranged_touch: bool = False,
|
||||
attack_penalty: int = 0,
|
||||
) -> str:
|
||||
if isinstance(effect, AreaEffect):
|
||||
return self._apply_area_effect(
|
||||
@@ -5741,7 +5779,8 @@ class CombatEngine:
|
||||
return self._apply_targeted_spell_effect(
|
||||
effect, target, caster_live, target_live,
|
||||
caster=caster, saved=saved, empower=empower, maximize=maximize,
|
||||
spell_name=spell_name, crit=crit,
|
||||
spell_name=spell_name, crit=crit, ranged_touch=ranged_touch,
|
||||
attack_penalty=attack_penalty,
|
||||
)
|
||||
|
||||
def _apply_targeted_spell_effect(
|
||||
@@ -5757,12 +5796,14 @@ class CombatEngine:
|
||||
maximize: bool,
|
||||
spell_name: str,
|
||||
crit: bool = False,
|
||||
ranged_touch: bool = False,
|
||||
attack_penalty: int = 0,
|
||||
) -> str:
|
||||
if isinstance(effect, DamageEffect):
|
||||
return self._apply_damage_effect(
|
||||
effect, target, caster_live, target_live,
|
||||
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
||||
crit=crit,
|
||||
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
||||
)
|
||||
if isinstance(effect, ConditionEffect):
|
||||
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
||||
@@ -5782,37 +5823,66 @@ class CombatEngine:
|
||||
maximize: bool,
|
||||
caster: CombatantState,
|
||||
crit: bool = False,
|
||||
ranged_touch: bool = False,
|
||||
attack_penalty: int = 0,
|
||||
) -> str:
|
||||
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
||||
# against the caster's level at cast time; the scaled formula then
|
||||
# flows through crits, energy resistance and metamagic unchanged.
|
||||
formula = effect.effective_formula(caster.combatant.level)
|
||||
raw = formula.roll(self._rng)
|
||||
if maximize:
|
||||
raw = formula.max_value()
|
||||
if crit:
|
||||
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
||||
# deals damage threatens on a natural 20 and deals double damage
|
||||
# on a successful critical hit.
|
||||
raw *= 2
|
||||
if saved and effect.half_on_save:
|
||||
dmg = max(0, raw // 2)
|
||||
elif saved and effect.save_negates:
|
||||
dmg = 0
|
||||
else:
|
||||
dmg = raw
|
||||
if empower:
|
||||
dmg = int(dmg * 1.5)
|
||||
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||
target.hp -= dmg
|
||||
caster_live.damage_dealt += dmg
|
||||
target_live.damage_taken += dmg
|
||||
self._check_reactions(
|
||||
ReactionEvent(
|
||||
kind="damage_taken", actor=caster, target=target, damage=dmg
|
||||
)
|
||||
# PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus
|
||||
# one additional missile per two caster levels beyond 1st [...]
|
||||
# maximum of five"; Scorching Ray: "one ray, plus one additional ray
|
||||
# for every four levels beyond 3rd [...] maximum of three"): each
|
||||
# projectile rolls its own damage and, for a ray, its own attack.
|
||||
count = (
|
||||
effect.projectiles.count_for(caster.combatant.level)
|
||||
if effect.projectiles is not None
|
||||
else 1
|
||||
)
|
||||
return f"{dmg} damage"
|
||||
total = 0
|
||||
for _ in range(count):
|
||||
proj_crit = crit
|
||||
if ranged_touch:
|
||||
# Each ray rolls its own ranged touch attack (cover,
|
||||
# concealment and critical threat included).
|
||||
hit, proj_crit = self._spell_ranged_touch_attack_hits(
|
||||
caster, target,
|
||||
attack_penalty=attack_penalty, deals_damage=True,
|
||||
)
|
||||
if not hit:
|
||||
continue
|
||||
raw = formula.roll(self._rng)
|
||||
if maximize:
|
||||
raw = formula.max_value()
|
||||
if proj_crit:
|
||||
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
||||
# deals damage threatens on a natural 20 and deals double
|
||||
# damage on a successful critical hit.
|
||||
raw *= 2
|
||||
if saved and effect.half_on_save:
|
||||
dmg = max(0, raw // 2)
|
||||
elif saved and effect.save_negates:
|
||||
dmg = 0
|
||||
else:
|
||||
dmg = raw
|
||||
if empower:
|
||||
dmg = int(dmg * 1.5)
|
||||
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||
target.hp -= dmg
|
||||
caster_live.damage_dealt += dmg
|
||||
target_live.damage_taken += dmg
|
||||
self._check_reactions(
|
||||
ReactionEvent(
|
||||
kind="damage_taken", actor=caster, target=target, damage=dmg
|
||||
)
|
||||
)
|
||||
total += dmg
|
||||
# PF1e: if the target drops mid-sequence the remaining projectiles
|
||||
# are lost (the engine's existing multi-attack convention).
|
||||
if not target.active:
|
||||
break
|
||||
return f"{total} damage"
|
||||
|
||||
def _apply_condition_effect(
|
||||
self,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user