147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""Batch 3 (V6c): the final 32 PC-sheet spells added to data/spells/.
|
|
|
|
Given: the registry directory with the 11 original spells plus batches 1-2 (75)
|
|
When: load_spell_registry / load_spell is called
|
|
Then: every batch-3 spell loads with the expected level/school/range/save,
|
|
the registry reaches exactly 107 (11 + 96), every effect validates against the
|
|
schema (parsing raises on any unknown field value or condition), and the
|
|
registry covers EVERY spell name referenced by data/pcs/*.json (no missing
|
|
spell remains).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
|
from pf1e_simulator.spells import (
|
|
AreaEffect,
|
|
BuffEffect,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
HealEffect,
|
|
SpellEffect,
|
|
SpellSpec,
|
|
SummonEffect,
|
|
load_spell,
|
|
load_spell_registry,
|
|
)
|
|
|
|
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
|
_PCS_DIR = Path(__file__).resolve().parent.parent / "data" / "pcs"
|
|
|
|
_BATCH2_COUNT = 75
|
|
|
|
# name -> (file stem, level, school, range, save)
|
|
_BATCH3: dict[str, tuple[str, int, str, str, str]] = {
|
|
"Ray of Enfeeblement": ("ray_of_enfeeblement", 1, "necromancy", "close", "fort"),
|
|
"Ray of Frost": ("ray_of_frost", 0, "evocation", "close", "none"),
|
|
"Read Magic": ("read_magic", 0, "divination", "personal", "none"),
|
|
"Resistance": ("resistance", 0, "abjuration", "touch", "will"),
|
|
"Restoration, Lesser": ("restoration_lesser", 2, "conjuration", "touch", "will"),
|
|
"Sanctuary": ("sanctuary", 1, "abjuration", "touch", "will"),
|
|
"Saving Finale": ("saving_finale", 1, "evocation", "close", "will"),
|
|
"Scorching Ray": ("scorching_ray", 2, "evocation", "close", "none"),
|
|
"Shard of Chaos": ("shard_of_chaos", 2, "evocation", "close", "will"),
|
|
"Shield": ("shield", 1, "abjuration", "personal", "none"),
|
|
"Shield of Faith": ("shield_of_faith", 1, "abjuration", "touch", "will"),
|
|
"Shocking Grasp": ("shocking_grasp", 1, "evocation", "touch", "none"),
|
|
"Silence": ("silence", 2, "illusion", "long", "will"),
|
|
"Sleep": ("sleep", 1, "enchantment", "medium", "will"),
|
|
"Sound Burst": ("sound_burst", 2, "evocation", "close", "fort"),
|
|
"Spark": ("spark", 0, "evocation", "close", "fort"),
|
|
"Spear of Purity": ("spear_of_purity", 2, "evocation", "close", "will"),
|
|
"Spiritual Weapon": ("spiritual_weapon", 2, "evocation", "medium", "none"),
|
|
"Stabilize": ("stabilize", 0, "conjuration", "close", "will"),
|
|
"Status": ("status", 2, "divination", "touch", "will"),
|
|
"Stave Off Corruption": ("stave_off_corruption", 2, "abjuration", "touch", "will"),
|
|
"Summon Instrument": ("summon_instrument", 0, "conjuration", "personal", "none"),
|
|
"Summon Monster I": ("summon_monster_i", 1, "conjuration", "close", "none"),
|
|
"Suppress Charms and Compulsions": (
|
|
"suppress_charms_and_compulsions", 2, "abjuration", "close", "will",
|
|
),
|
|
"Surmount Affliction": ("surmount_affliction", 2, "abjuration", "personal", "none"),
|
|
"Tears to Wine": ("tears_to_wine", 2, "transmutation", "close", "will"),
|
|
"Touch of Fatigue": ("touch_of_fatigue", 0, "necromancy", "touch", "fort"),
|
|
"Unseen Servant": ("unseen_servant", 1, "conjuration", "close", "none"),
|
|
"Vanish": ("vanish", 1, "illusion", "touch", "will"),
|
|
"Virtue": ("virtue", 0, "transmutation", "touch", "none"),
|
|
"Weapon of Awe": ("weapon_of_awe", 2, "transmutation", "touch", "will"),
|
|
"Web": ("web", 2, "conjuration", "medium", "ref"),
|
|
}
|
|
|
|
_EFFECT_TYPES = (
|
|
DamageEffect,
|
|
ConditionEffect,
|
|
HealEffect,
|
|
BuffEffect,
|
|
AreaEffect,
|
|
SummonEffect,
|
|
)
|
|
|
|
|
|
def _walk_effects(effects: tuple[SpellEffect, ...]) -> list[SpellEffect]:
|
|
flat: list[SpellEffect] = []
|
|
for effect in effects:
|
|
flat.append(effect)
|
|
if isinstance(effect, AreaEffect):
|
|
flat.extend(_walk_effects(effect.effects))
|
|
return flat
|
|
|
|
|
|
class TestBatch3Loading:
|
|
"""Each batch-3 spell loads with the expected metadata."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "stem", "level", "school", "range_", "save"),
|
|
[
|
|
(name, *fields) for name, fields in sorted(_BATCH3.items())
|
|
],
|
|
)
|
|
def test_spell_fields(
|
|
self, name: str, stem: str, level: int, school: str, range_: str, save: str
|
|
) -> None:
|
|
spell = load_spell(_SPELLS_DIR / f"{stem}.json")
|
|
assert spell.name == name
|
|
assert spell.level == level
|
|
assert spell.school == school
|
|
assert spell.range == range_
|
|
assert spell.save == save
|
|
assert spell.base_dc == 10
|
|
assert spell.description
|
|
|
|
def test_registry_reaches_107(self) -> None:
|
|
reg = load_spell_registry(_SPELLS_DIR)
|
|
assert len(reg) == _BATCH2_COUNT + 32 == 107
|
|
assert set(_BATCH3) <= set(reg)
|
|
|
|
def test_every_effect_validates(self) -> None:
|
|
reg = load_spell_registry(_SPELLS_DIR)
|
|
for name in _BATCH3:
|
|
spell: SpellSpec = reg[name]
|
|
for effect in _walk_effects(spell.effects):
|
|
assert isinstance(effect, _EFFECT_TYPES)
|
|
if isinstance(effect, ConditionEffect):
|
|
assert effect.condition_name in CONDITIONS_BY_NAME
|
|
if isinstance(effect, BuffEffect):
|
|
assert effect.modifiers
|
|
if isinstance(effect, SummonEffect):
|
|
assert effect.creature
|
|
|
|
|
|
class TestFullPcSpellCoverage:
|
|
"""The registry now covers every spell name referenced by the PC sheets."""
|
|
|
|
def test_no_pc_spell_is_missing_from_the_registry(self) -> None:
|
|
reg = load_spell_registry(_SPELLS_DIR)
|
|
missing: set[tuple[str, str]] = set()
|
|
for path in sorted(_PCS_DIR.glob("*.json")):
|
|
data = json.loads(path.read_text())
|
|
for spell_name in data.get("spells", []):
|
|
if spell_name not in reg:
|
|
missing.add((path.name, spell_name))
|
|
assert not missing
|