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
+22 -9
View File
@@ -940,7 +940,27 @@ Règles modélisées :
inchangé. Simplification documentée : le scalage ne s'applique qu'aux
dégâts (`DamageEffect`) — les soins par niveau (Cure Light Wounds
« +1 per caster level (maximum +5) ») ne sont pas scalés (la fiche dorée
épingle `1d8+1`).
épingle `1d8+1`).
- Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer
`"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le
nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels`
(delta de niveau planché à 0), plafonné à `max` (SRD Magic Missile : « one
missile, plus one additional missile per two caster levels beyond 1st [...]
maximum of five » → 1 au niveau 1, 2 au niveau 4, 5 au niveau 9 ; Scorching
Ray : `per_levels` 4, `at_level` 3, `max` 3 → 1/2/3 rayons aux niveaux
1/7/11). À l'incantation, chaque projectile **jetle ses propres dégâts** et,
si le sort exige une attaque de contact à distance (`requires_ranged_touch`,
Scorching Ray), **jetle sa propre attaque de contact à distance** (couvert,
dissimulation et menace de critique incluss — un rayon qui rate ne inflige
rien, les autres partent quand même) ; les dégâts de sort ne sont pas réduits
par la RD, et la résistance énergétique s'applique à chaque projectile. La RS
est opposée une fois par cible (le moteur ne répartit pas les projectiles sur
plusieurs cibles — toutes touchent la cible résolue). Si la cible tombe en
cours de séquence (pv < 0 ou morte), les projectiles restants sont **perdus**
(même convention que les attaques multiples d'une arme `count` > 1). Magic
Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes
sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11)
tire désormais ses cinq missiles.
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
@@ -1393,7 +1413,7 @@ plutôt que bricolées) :
Dextérité/Force du CMB (conformance L1) tient compte de leur taille.
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le
moteur n'a ni projectiles multiples, ni sauvegarde partielle) :
moteur n'a pas encore de sauvegarde partielle) :
- **Acid Arrow — sans dégâts continus** (SRD spells/acid-arrow.md : portée
**longue** — corrigée, conformance D7 — et « For every three caster levels
@@ -1409,13 +1429,6 @@ moteur n'a ni projectiles multiples, ni sauvegarde partielle) :
bien `panicked` (conformance D8), mais le moteur n'a pas de sauvegarde
partielle pour les conditions — un succès annule totalement l'effet au lieu
de poser `shaken` 1 round ; la durée « 1 round/niveau » est fixée à 4 rounds.
- **Magic Missile — un seul projectile** (SRD spells/magic-missile.md :
« You strike [...] with one missile, plus one additional missile per two
caster levels beyond 1st [...] maximum of five missiles ») : le moteur
n'a pas de scalage multi-projectiles ni de répartition sur plusieurs
cibles ; le sort tire une unique missile (1d4+1 force, touche
automatiquement) sur la cible résolue (conformance D9 — documenté, la
fiche dorée est inchangée).
- **Résistance aux sorts appliquée à tout sort ciblé** : le moteur oppose la
SR du combattant à chaque sort qui le cible, sans drapeau par sort ; les
rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid
+3 -2
View File
@@ -11,8 +11,9 @@
"type": "damage",
"formula": "1d4+1",
"types": ["force"],
"half_on_save": false
"half_on_save": false,
"projectiles": { "base": 1, "per_levels": 2, "at_level": 1, "max": 5 }
}
],
"description": "A missile of magical energy darts forth from your fingertip and strikes its target, dealing 1d4+1 points of force damage."
"description": "SRD spells/magic-missile.md: unerring force missiles, 1d4+1 points of force damage each. One missile, plus one additional missile per two caster levels beyond 1st (two at 3rd, three at 5th, four at 7th, maximum five at 9th or higher). Auto-hit (no attack roll); spell resistance applies to the target."
}
+3 -2
View File
@@ -12,8 +12,9 @@
"type": "damage",
"formula": "4d6",
"types": ["fire"],
"half_on_save": false
"half_on_save": false,
"projectiles": { "base": 1, "per_levels": 4, "at_level": 3, "max": 3 }
}
],
"description": "You blast your enemy with a searing beam of fire: each ray requires a ranged touch attack and deals 4d6 points of fire damage. You fire one ray, plus one additional ray per four levels beyond 3rd (max three at 11th) — the engine models a single ray. No save."
"description": "SRD spells/scorching-ray.md: a searing beam of fire. One ray, plus one additional ray for every four caster levels beyond 3rd (to a maximum of three rays at 11th level). Each ray requires a ranged touch attack to hit and deals 4d6 points of fire damage. No save."
}
+99 -29
View File
@@ -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
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(
+5 -4
View File
@@ -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:
+263
View File
@@ -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)
+11 -20
View 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
View File
@@ -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