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:
@@ -940,7 +940,27 @@ Règles modélisées :
|
|||||||
inchangé. Simplification documentée : le scalage ne s'applique qu'aux
|
inchangé. Simplification documentée : le scalage ne s'applique qu'aux
|
||||||
dégâts (`DamageEffect`) — les soins par niveau (Cure Light Wounds
|
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
|
« +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
|
- 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
|
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
|
`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.
|
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
|
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
|
- **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
|
**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
|
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
|
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.
|
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
|
- **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
|
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
|
rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
"type": "damage",
|
"type": "damage",
|
||||||
"formula": "1d4+1",
|
"formula": "1d4+1",
|
||||||
"types": ["force"],
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
"type": "damage",
|
"type": "damage",
|
||||||
"formula": "4d6",
|
"formula": "4d6",
|
||||||
"types": ["fire"],
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,21 @@ def _spell_half_on_save(spell: SpellSpec) -> bool:
|
|||||||
return False
|
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:
|
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.
|
"""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(
|
self._apply_and_log_cast(
|
||||||
caster, target, spell, spell_name, center_pos,
|
caster, target, spell, spell_name, center_pos,
|
||||||
metamagic=applied, slot_level=used_level, spell_crit=delivery_crit,
|
metamagic=applied, slot_level=used_level, spell_crit=delivery_crit,
|
||||||
|
attack_penalty=attack_penalty,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -5369,6 +5385,10 @@ class CombatEngine:
|
|||||||
(the spell's damage dice are then doubled)."""
|
(the spell's damage dice are then doubled)."""
|
||||||
crit = False
|
crit = False
|
||||||
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
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 (
|
if (
|
||||||
target is not None
|
target is not None
|
||||||
and spell.range == "touch"
|
and spell.range == "touch"
|
||||||
@@ -5384,6 +5404,7 @@ class CombatEngine:
|
|||||||
target is not None
|
target is not None
|
||||||
and spell.requires_ranged_touch
|
and spell.requires_ranged_touch
|
||||||
and not has_area
|
and not has_area
|
||||||
|
and not per_projectile_attack
|
||||||
and target.side != caster.side
|
and target.side != caster.side
|
||||||
):
|
):
|
||||||
hit, ranged_crit = self._spell_ranged_touch_attack_hits(
|
hit, ranged_crit = self._spell_ranged_touch_attack_hits(
|
||||||
@@ -5609,6 +5630,7 @@ class CombatEngine:
|
|||||||
metamagic: tuple[str, ...] = (),
|
metamagic: tuple[str, ...] = (),
|
||||||
slot_level: int | None = None,
|
slot_level: int | None = None,
|
||||||
spell_crit: bool = False,
|
spell_crit: bool = False,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Resolve save, apply effects and log a spell cast (post-gates).
|
"""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
|
uses the DC of the higher slot level). ``spell_crit`` is a confirmed
|
||||||
touch-delivery critical: the spell's damage dice are doubled (CRB
|
touch-delivery critical: the spell's damage dice are doubled (CRB
|
||||||
"Range — Touch": "deals double damage on a successful critical hit").
|
"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)
|
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
||||||
save_result = (
|
save_result = (
|
||||||
@@ -5633,6 +5657,15 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
# Apply only the metamagic feats chosen for this cast (B7)
|
# Apply only the metamagic feats chosen for this cast (B7)
|
||||||
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
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
|
hp_before = target.hp if target is not None else caster.hp
|
||||||
parts = self._apply_spell_effects_from_list(
|
parts = self._apply_spell_effects_from_list(
|
||||||
metamagic_effects, target, save_result, caster_live, target_live,
|
metamagic_effects, target, save_result, caster_live, target_live,
|
||||||
@@ -5640,7 +5673,8 @@ class CombatEngine:
|
|||||||
empower="Empower Spell" in metamagic,
|
empower="Empower Spell" in metamagic,
|
||||||
maximize="Maximize Spell" in metamagic,
|
maximize="Maximize Spell" in metamagic,
|
||||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
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)
|
save_str = _save_label(save_result)
|
||||||
if target is None and center_pos is not None:
|
if target is None and center_pos is not None:
|
||||||
@@ -5697,6 +5731,8 @@ class CombatEngine:
|
|||||||
center_pos: tuple[int, int] | None = None,
|
center_pos: tuple[int, int] | None = None,
|
||||||
save_type: SaveType = "none",
|
save_type: SaveType = "none",
|
||||||
crit: bool = False,
|
crit: bool = False,
|
||||||
|
ranged_touch: bool = False,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
saved = save_result is not None and save_result.success
|
saved = save_result is not None and save_result.success
|
||||||
return [
|
return [
|
||||||
@@ -5705,7 +5741,7 @@ class CombatEngine:
|
|||||||
caster=caster, spell_name=spell_name,
|
caster=caster, spell_name=spell_name,
|
||||||
empower=empower, maximize=maximize, saved=saved,
|
empower=empower, maximize=maximize, saved=saved,
|
||||||
dc=dc, center_pos=center_pos, save_type=save_type,
|
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
|
for effect in effects
|
||||||
]
|
]
|
||||||
@@ -5726,6 +5762,8 @@ class CombatEngine:
|
|||||||
center_pos: tuple[int, int] | None = None,
|
center_pos: tuple[int, int] | None = None,
|
||||||
save_type: SaveType = "none",
|
save_type: SaveType = "none",
|
||||||
crit: bool = False,
|
crit: bool = False,
|
||||||
|
ranged_touch: bool = False,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
if isinstance(effect, AreaEffect):
|
if isinstance(effect, AreaEffect):
|
||||||
return self._apply_area_effect(
|
return self._apply_area_effect(
|
||||||
@@ -5741,7 +5779,8 @@ class CombatEngine:
|
|||||||
return self._apply_targeted_spell_effect(
|
return self._apply_targeted_spell_effect(
|
||||||
effect, target, caster_live, target_live,
|
effect, target, caster_live, target_live,
|
||||||
caster=caster, saved=saved, empower=empower, maximize=maximize,
|
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(
|
def _apply_targeted_spell_effect(
|
||||||
@@ -5757,12 +5796,14 @@ class CombatEngine:
|
|||||||
maximize: bool,
|
maximize: bool,
|
||||||
spell_name: str,
|
spell_name: str,
|
||||||
crit: bool = False,
|
crit: bool = False,
|
||||||
|
ranged_touch: bool = False,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
if isinstance(effect, DamageEffect):
|
if isinstance(effect, DamageEffect):
|
||||||
return self._apply_damage_effect(
|
return self._apply_damage_effect(
|
||||||
effect, target, caster_live, target_live,
|
effect, target, caster_live, target_live,
|
||||||
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
||||||
crit=crit,
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
||||||
)
|
)
|
||||||
if isinstance(effect, ConditionEffect):
|
if isinstance(effect, ConditionEffect):
|
||||||
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
||||||
@@ -5782,37 +5823,66 @@ class CombatEngine:
|
|||||||
maximize: bool,
|
maximize: bool,
|
||||||
caster: CombatantState,
|
caster: CombatantState,
|
||||||
crit: bool = False,
|
crit: bool = False,
|
||||||
|
ranged_touch: bool = False,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
||||||
# against the caster's level at cast time; the scaled formula then
|
# against the caster's level at cast time; the scaled formula then
|
||||||
# flows through crits, energy resistance and metamagic unchanged.
|
# flows through crits, energy resistance and metamagic unchanged.
|
||||||
formula = effect.effective_formula(caster.combatant.level)
|
formula = effect.effective_formula(caster.combatant.level)
|
||||||
raw = formula.roll(self._rng)
|
# PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus
|
||||||
if maximize:
|
# one additional missile per two caster levels beyond 1st [...]
|
||||||
raw = formula.max_value()
|
# maximum of five"; Scorching Ray: "one ray, plus one additional ray
|
||||||
if crit:
|
# for every four levels beyond 3rd [...] maximum of three"): each
|
||||||
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
# projectile rolls its own damage and, for a ray, its own attack.
|
||||||
# deals damage threatens on a natural 20 and deals double damage
|
count = (
|
||||||
# on a successful critical hit.
|
effect.projectiles.count_for(caster.combatant.level)
|
||||||
raw *= 2
|
if effect.projectiles is not None
|
||||||
if saved and effect.half_on_save:
|
else 1
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
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(
|
def _apply_condition_effect(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -63,6 +63,36 @@ class DamageFlatScaling:
|
|||||||
max_amount: int
|
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)
|
@dataclass(frozen=True)
|
||||||
class DamageEffect:
|
class DamageEffect:
|
||||||
formula: DiceExpr
|
formula: DiceExpr
|
||||||
@@ -79,6 +109,10 @@ class DamageEffect:
|
|||||||
# level-independent damage, the pre-S1b behaviour.
|
# level-independent damage, the pre-S1b behaviour.
|
||||||
scaling: DamageDieScaling | None = None
|
scaling: DamageDieScaling | None = None
|
||||||
scaling_flat: DamageFlatScaling | 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:
|
def effective_formula(self, caster_level: int) -> DiceExpr:
|
||||||
"""The damage formula resolved for a caster of ``caster_level``.
|
"""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:
|
def _parse_effect(data: Json) -> SpellEffect:
|
||||||
effect = _as_dict(data)
|
effect = _as_dict(data)
|
||||||
effect_type = _as_str(effect["type"])
|
effect_type = _as_str(effect["type"])
|
||||||
@@ -376,6 +436,8 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
flat_raw = effect.get("scaling_flat")
|
flat_raw = effect.get("scaling_flat")
|
||||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
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(
|
return DamageEffect(
|
||||||
formula=formula,
|
formula=formula,
|
||||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
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)),
|
save_negates=bool(effect.get("save_negates", True)),
|
||||||
scaling=scaling,
|
scaling=scaling,
|
||||||
scaling_flat=scaling_flat,
|
scaling_flat=scaling_flat,
|
||||||
|
projectiles=projectiles,
|
||||||
)
|
)
|
||||||
if effect_type == "condition":
|
if effect_type == "condition":
|
||||||
return ConditionEffect(
|
return ConditionEffect(
|
||||||
|
|||||||
@@ -550,20 +550,21 @@ class TestTouchDeliveryDefense:
|
|||||||
|
|
||||||
def test_magic_missile_still_auto_hits(self) -> None:
|
def test_magic_missile_still_auto_hits(self) -> None:
|
||||||
# Given: the real Magic Missile spell (no ranged touch requirement)
|
# Given: the real Magic Missile spell (no ranged touch requirement)
|
||||||
# When: cast with only a damage roll in the queue
|
# When: cast with only damage rolls in the queue (a level-5 caster
|
||||||
# Then: no attack roll is consumed — the missile always hits.
|
# 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")
|
missile = load_spell(SPELLS_DIR / "magic_missile.json")
|
||||||
assert missile.requires_ranged_touch is False
|
assert missile.requires_ranged_touch is False
|
||||||
c = _state(_caster(), "players", (0, 0))
|
c = _state(_caster(), "players", (0, 0))
|
||||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
engine = CombatEngine(
|
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(),
|
_grid(),
|
||||||
[c, f],
|
[c, f],
|
||||||
spell_registry={"Magic Missile": missile},
|
spell_registry={"Magic Missile": missile},
|
||||||
)
|
)
|
||||||
engine._cast_spell(c, f, "Magic 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)
|
assert not any("ranged touch attack" in line for line in engine._transcript)
|
||||||
|
|
||||||
def test_requires_ranged_touch_parses_from_spell_json(self) -> None:
|
def test_requires_ranged_touch_parses_from_spell_json(self) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -92,33 +92,24 @@ class TestGoldenCombats:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_tammara_vs_lich(self):
|
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")
|
result, _ = run_golden("tammara", "lich")
|
||||||
assert result.winner == "monsters"
|
assert result.winner == "monsters"
|
||||||
assert result.rounds == 6
|
assert result.rounds == 1
|
||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: tammara d20=15+7=22",
|
"initiative: tammara d20=15+7=22",
|
||||||
"initiative: lich d20=10+6=16",
|
"initiative: lich d20=10+6=16",
|
||||||
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
||||||
"(36->36)"),
|
"(36->36)"),
|
||||||
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (36->20)",
|
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)",
|
||||||
("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) "
|
"battle over: monsters win in 1 rounds",
|
||||||
"(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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _assert_deterministic_goblin(self, pc_name: str):
|
def _assert_deterministic_goblin(self, pc_name: str):
|
||||||
|
|||||||
+13
-8
@@ -283,21 +283,23 @@ class TestSpellRangeFt:
|
|||||||
class TestMagicMissile:
|
class TestMagicMissile:
|
||||||
"""Given: a caster casting Magic Missile (no save, force damage)
|
"""Given: a caster casting Magic Missile (no save, force damage)
|
||||||
When: _cast_spell is called
|
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:
|
def test_deals_damage_no_save(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=100)
|
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
|
hp_before = t.hp
|
||||||
engine._cast_spell(c, t, "Magic Missile")
|
engine._cast_spell(c, t, "Magic Missile")
|
||||||
assert t.hp < hp_before
|
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:
|
def test_transcript_shows_no_save(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
target = _make_target(hp_max=100)
|
||||||
engine, c, t = _make_engine(caster, target, [3])
|
engine, c, t = _make_engine(caster, target, [3, 3, 3])
|
||||||
engine._cast_spell(c, t, "Magic Missile")
|
engine._cast_spell(c, t, "Magic Missile")
|
||||||
assert any("NO SAVE" in line for line in engine._transcript)
|
assert any("NO SAVE" in line for line in engine._transcript)
|
||||||
|
|
||||||
@@ -636,7 +638,8 @@ class TestSpellDispatchViaExecute:
|
|||||||
def test_execute_cast_spell(self) -> None:
|
def test_execute_cast_spell(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=100)
|
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
|
hp_before = t.hp
|
||||||
engine._execute(
|
engine._execute(
|
||||||
c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile")
|
c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile")
|
||||||
@@ -710,7 +713,8 @@ class TestSpellSlots:
|
|||||||
def test_slot_depense(self) -> None:
|
def test_slot_depense(self) -> None:
|
||||||
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
|
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
|
||||||
target = _make_target(hp_max=100)
|
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
|
engine._cast_spell(c, t, "Magic Missile") # level 1
|
||||||
assert c.spell_slots_left[1] == 1
|
assert c.spell_slots_left[1] == 1
|
||||||
assert c.spell_slots_left[2] == 1
|
assert c.spell_slots_left[2] == 1
|
||||||
@@ -720,7 +724,8 @@ class TestSpellSlots:
|
|||||||
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
|
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
|
||||||
)
|
)
|
||||||
target = _make_target(hp_max=100)
|
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
|
assert _choose_spell_action(engine, c, t) is not None
|
||||||
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
|
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
|
||||||
assert c.spell_slots_left[1] == 0
|
assert c.spell_slots_left[1] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user