Files
pf1e-simulator/tests/test_spell_registry_batch2.py
T
ctan 08f9af9e3e fix(rules): guidance is a one-shot +1 competence bonus on a single attack roll - SRD spells/guidance.md grants '+1 competence bonus on a SINGLE attack roll, saving throw, or skill check' (duration 1 minute or until discharged); the engine had no spell effect that could write the one-shot attack track (aid_attack_bonus is hardwired to aid-another +2 and expires at the start of the beneficiary's next turn), and a persistent BuffEffect would misrepresent the discharge rule (it would boost every roll for a minute) and would become the first buff spell in the casters' known-spell order, degrading default_policy; a new OneShotEffect type is added (track/value/bonus_type/duration_rounds, parser 'one_shot', SpellEffect union, summary 'one-shot +X type on track'), stored as ActiveOneShot on CombatantState (one_shot_bonuses, decremented with the other duration-tracked effects, exposed in the snapshot), discharged by the FIRST roll of the encoded track via _discharge_one_shot (best value wins, each casting is its own charge) and folded into the resolve_modifiers pool so bonus-type stacking applies; the attack track rides the attack roll only, NOT the crit-confirm roll (the SRD grants it to a SINGLE attack roll, so unlike aid another the confirmation is untouched - _attack_roll_modifiers returns (pool_mod, attack_roll_mod) and resolve_attack uses the latter for the total while the confirm keeps the former); the save and skill tracks are wired in the mechanism (resolve_save folds a save-track shot into save_mods, resolve_skill discharges a skill-track shot into _skill_static_bonus via extra_mods) but guidance.json encodes the attack track only - the SRD 'attack OR save OR skill' choice has no declaration channel in the engine, so the attack-roll variant is encoded (documented); _apply_targeted_spell_effect dispatches OneShotEffect to _apply_one_shot_effect (a successful save negates it, 'one-shot negated'); guidance.json now carries the effect (1 minute = 10 rounds) and default_policy never casts it (cantrips have no spell slot and the one-shot type is not a buff/heal/offensive pick); _spell_effect_summary split into _damage_effect_summary/_non_damage_effect_summary to stay under the PLR0911 return cap; test_spell_registry_batch2._EFFECT_TYPES gains OneShotEffect; +14 tests (test_one_shot_guidance.py: cast-and-discharge on the next attack, not on the crit-confirm roll, save/skill tracks, stacking by bonus type, expiry at 10 rounds, save negation, snapshot exposure, no auto-cast by default_policy); golden untouched; 1705 tests green
2026-09-20 09:53:53 +02:00

134 lines
5.3 KiB
Python

"""Batch 2 (V6b): the next 32 PC-sheet spells added to data/spells/.
Given: the registry directory with the 11 original spells plus batch 1 (43)
When: load_spell_registry / load_spell is called
Then: every batch-2 spell loads with the expected level/school/range/save,
the registry grew by exactly 32 over batch 1, and every effect validates
against the schema (parsing raises on any unknown field value or condition).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.spells import (
AreaEffect,
BuffEffect,
ConditionEffect,
DamageEffect,
HealEffect,
OneShotEffect,
SpellEffect,
SpellSpec,
SummonEffect,
load_spell,
load_spell_registry,
)
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
_BATCH1_COUNT = 43
# name -> (file stem, level, school, range, save)
_BATCH2: dict[str, tuple[str, int, str, str, str]] = {
"Enhanced Diplomacy": ("enhanced_diplomacy", 0, "divination", "touch", "will"),
"Face of the Devourer": ("face_of_the_devourer", 1, "transmutation", "touch", "fort"),
"Flurry of Snowballs": ("flurry_of_snowballs", 2, "evocation", "close", "ref"),
"Forbid Action": ("forbid_action", 1, "enchantment", "close", "will"),
"Grasp": ("grasp", 0, "divination", "personal", "none"),
"Guidance": ("guidance", 0, "divination", "touch", "will"),
"Heroism": ("heroism", 2, "enchantment", "touch", "will"),
"Hex Vulnerability": ("hex_vulnerability", 1, "necromancy", "close", "will"),
"Hidden Diplomacy": ("hidden_diplomacy", 1, "enchantment", "close", "will"),
"Hide from Undead": ("hide_from_undead", 1, "abjuration", "touch", "will"),
"Ice Slick": ("ice_slick", 2, "evocation", "close", "ref"),
"Ill Omen": ("ill_omen", 1, "enchantment", "close", "none"),
"Inflict Light Wounds": ("inflict_light_wounds", 1, "necromancy", "touch", "will"),
"Ironskin": ("ironskin", 2, "transmutation", "personal", "none"),
"Light": ("light", 0, "evocation", "touch", "none"),
"Mage Hand": ("mage_hand", 0, "transmutation", "close", "none"),
"Make Whole": ("make_whole", 2, "transmutation", "close", "will"),
"Masterwork Transformation": (
"masterwork_transformation", 2, "transmutation", "touch", "none",
),
"Mending": ("mending", 0, "transmutation", "close", "will"),
"Message": ("message", 0, "transmutation", "medium", "none"),
"Mirror Image": ("mirror_image", 2, "illusion", "personal", "none"),
"Oath of Anonymity": ("oath_of_anonymity", 0, "illusion", "personal", "none"),
"Obscuring Mist": ("obscuring_mist", 1, "conjuration", "close", "none"),
"Open/Close": ("open_close", 0, "transmutation", "close", "will"),
"Owl's Wisdom": ("owls_wisdom", 2, "transmutation", "touch", "will"),
"Prestidigitation": ("prestidigitation", 0, "universal", "close", "none"),
"Protection from Evil": ("protection_from_evil", 1, "abjuration", "touch", "will"),
"Protection from Evil, Communal": (
"protection_from_evil_communal", 2, "abjuration", "touch", "will",
),
"Protection from Law": ("protection_from_law", 1, "abjuration", "touch", "will"),
"Protection from Law, Communal": (
"protection_from_law_communal", 2, "abjuration", "touch", "will",
),
"Purify Food and Drink": ("purify_food_and_drink", 0, "transmutation", "close", "will"),
"Putrefy Food and Drink": ("putrefy_food_and_drink", 0, "transmutation", "close", "will"),
}
_EFFECT_TYPES = (
DamageEffect,
ConditionEffect,
HealEffect,
BuffEffect,
OneShotEffect,
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 TestBatch2Loading:
"""Each batch-2 spell loads with the expected metadata."""
@pytest.mark.parametrize(
("name", "stem", "level", "school", "range_", "save"),
[
(name, *fields) for name, fields in sorted(_BATCH2.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_grew_by_exactly_32(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
# Batch 3 adds more spells; the exact total is pinned there.
assert len(reg) >= _BATCH1_COUNT + 32
assert set(_BATCH2) <= set(reg)
def test_every_effect_validates(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
for name in _BATCH2:
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