diff --git a/README.md b/README.md index 3af15c6..4408a60 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/spells/magic_missile.json b/data/spells/magic_missile.json index 841faca..cad17a6 100644 --- a/data/spells/magic_missile.json +++ b/data/spells/magic_missile.json @@ -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." } diff --git a/data/spells/scorching_ray.json b/data/spells/scorching_ray.json index bc867c9..b266814 100644 --- a/data/spells/scorching_ray.json +++ b/data/spells/scorching_ray.json @@ -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." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 480243c..e70fe3b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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, diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 1679498..425e4a7 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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( diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py index 50f0e38..1f6d963 100644 --- a/tests/test_conformance_spellflow.py +++ b/tests/test_conformance_spellflow.py @@ -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: diff --git a/tests/test_multi_projectile.py b/tests/test_multi_projectile.py new file mode 100644 index 0000000..e829c31 --- /dev/null +++ b/tests/test_multi_projectile.py @@ -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) diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index bdbe76e..a2367fb 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -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): diff --git a/tests/test_spells.py b/tests/test_spells.py index ac40911..aa39f98 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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