From 1ca5aed758df5ce41b8da21262719925d5da94ec Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 19:43:07 +0200 Subject: [PATCH] data(spells): add batch 3 of PC spell list (32 spells) --- README.md | 2 +- data/spells/ray_of_enfeeblement.json | 17 ++ data/spells/ray_of_frost.json | 17 ++ data/spells/read_magic.json | 10 ++ data/spells/resistance.json | 10 ++ data/spells/restoration_lesser.json | 10 ++ data/spells/sanctuary.json | 10 ++ data/spells/saving_finale.json | 10 ++ data/spells/scorching_ray.json | 17 ++ data/spells/shard_of_chaos.json | 10 ++ data/spells/shield.json | 18 +++ data/spells/shield_of_faith.json | 10 ++ data/spells/shocking_grasp.json | 17 ++ data/spells/silence.json | 10 ++ data/spells/sleep.json | 24 +++ data/spells/sound_burst.json | 30 ++++ data/spells/spark.json | 10 ++ data/spells/spear_of_purity.json | 10 ++ data/spells/spiritual_weapon.json | 10 ++ data/spells/stabilize.json | 10 ++ data/spells/status.json | 10 ++ data/spells/stave_off_corruption.json | 10 ++ data/spells/summon_instrument.json | 10 ++ data/spells/summon_monster_i.json | 16 ++ .../suppress_charms_and_compulsions.json | 10 ++ data/spells/surmount_affliction.json | 10 ++ data/spells/tears_to_wine.json | 10 ++ data/spells/touch_of_fatigue.json | 17 ++ data/spells/unseen_servant.json | 10 ++ data/spells/vanish.json | 17 ++ data/spells/virtue.json | 10 ++ data/spells/weapon_of_awe.json | 10 ++ data/spells/web.json | 24 +++ tests/test_runner_spells.py | 4 +- tests/test_spell_registry_batch2.py | 3 +- tests/test_spell_registry_batch3.py | 146 ++++++++++++++++++ tests/test_spells.py | 8 +- 37 files changed, 579 insertions(+), 8 deletions(-) create mode 100644 data/spells/ray_of_enfeeblement.json create mode 100644 data/spells/ray_of_frost.json create mode 100644 data/spells/read_magic.json create mode 100644 data/spells/resistance.json create mode 100644 data/spells/restoration_lesser.json create mode 100644 data/spells/sanctuary.json create mode 100644 data/spells/saving_finale.json create mode 100644 data/spells/scorching_ray.json create mode 100644 data/spells/shard_of_chaos.json create mode 100644 data/spells/shield.json create mode 100644 data/spells/shield_of_faith.json create mode 100644 data/spells/shocking_grasp.json create mode 100644 data/spells/silence.json create mode 100644 data/spells/sleep.json create mode 100644 data/spells/sound_burst.json create mode 100644 data/spells/spark.json create mode 100644 data/spells/spear_of_purity.json create mode 100644 data/spells/spiritual_weapon.json create mode 100644 data/spells/stabilize.json create mode 100644 data/spells/status.json create mode 100644 data/spells/stave_off_corruption.json create mode 100644 data/spells/summon_instrument.json create mode 100644 data/spells/summon_monster_i.json create mode 100644 data/spells/suppress_charms_and_compulsions.json create mode 100644 data/spells/surmount_affliction.json create mode 100644 data/spells/tears_to_wine.json create mode 100644 data/spells/touch_of_fatigue.json create mode 100644 data/spells/unseen_servant.json create mode 100644 data/spells/vanish.json create mode 100644 data/spells/virtue.json create mode 100644 data/spells/weapon_of_awe.json create mode 100644 data/spells/web.json create mode 100644 tests/test_spell_registry_batch3.py diff --git a/README.md b/README.md index 76ed020..ac8bf99 100644 --- a/README.md +++ b/README.md @@ -922,7 +922,7 @@ uv run basedpyright src # mode strict ## Feuille de route - **Phase 1** — magie et états : largement réalisée. Sorts par emplacements - (`spells.py` + base `data/spells/`, 75 sorts), DD et sauvegardes, RD non + (`spells.py` + base `data/spells/`, 107 sorts), DD et sauvegardes, RD non appliquée aux dégâts de sorts, concentration par situation, invocation, conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms résolus, `abilities.py` + chargeur) et capacités de classe (channel energy, diff --git a/data/spells/ray_of_enfeeblement.json b/data/spells/ray_of_enfeeblement.json new file mode 100644 index 0000000..350277e --- /dev/null +++ b/data/spells/ray_of_enfeeblement.json @@ -0,0 +1,17 @@ +{ + "name": "Ray of Enfeeblement", + "level": 1, + "school": "necromancy", + "range": "close", + "save": "fort", + "base_dc": 10, + "effects": [ + { + "type": "condition", + "condition_name": "fatigued", + "duration_rounds": 4, + "save_negates": true + } + ], + "description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability damage, so the Strength penalty is modeled as the fatigued condition (attack/damage penalties, no charge) for 4 rounds (1 round/level at caster level 4); Fortitude halves is approximated as Fortitude negates." +} diff --git a/data/spells/ray_of_frost.json b/data/spells/ray_of_frost.json new file mode 100644 index 0000000..707a772 --- /dev/null +++ b/data/spells/ray_of_frost.json @@ -0,0 +1,17 @@ +{ + "name": "Ray of Frost", + "level": 0, + "school": "evocation", + "range": "close", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "1d3", + "types": ["cold"], + "half_on_save": false + } + ], + "description": "A cantrip: a ray of freezing air and ice projects from your pointing finger. You must succeed on a ranged touch attack to deal 1d3 points of cold damage to the target. No save." +} diff --git a/data/spells/read_magic.json b/data/spells/read_magic.json new file mode 100644 index 0000000..ef76d81 --- /dev/null +++ b/data/spells/read_magic.json @@ -0,0 +1,10 @@ +{ + "name": "Read Magic", + "level": 0, + "school": "divination", + "range": "personal", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "A cantrip: you can decipher magical inscriptions on objects that would otherwise be unintelligible, at one page per minute, and you can thereafter read that particular writing without the spell. Duration 10 min./level. No combat effect; reading magical writing is not modeled by the engine." +} diff --git a/data/spells/resistance.json b/data/spells/resistance.json new file mode 100644 index 0000000..5cb7652 --- /dev/null +++ b/data/spells/resistance.json @@ -0,0 +1,10 @@ +{ + "name": "Resistance", + "level": 0, + "school": "abjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "A cantrip: the subject gains a +1 resistance bonus on saving throws for 1 minute. Will negates (harmless). Simplified: the engine has no resistance bonus type and no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/restoration_lesser.json b/data/spells/restoration_lesser.json new file mode 100644 index 0000000..a01aec4 --- /dev/null +++ b/data/spells/restoration_lesser.json @@ -0,0 +1,10 @@ +{ + "name": "Restoration, Lesser", + "level": 2, + "school": "conjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "Casting time 3 rounds. Dispels any magical effects reducing one of the subject's ability scores or cures 1d4 points of temporary ability damage to one ability score, and eliminates any fatigue (improving exhausted to fatigued). Will negates (harmless). No combat effect encoded: the engine has no ability damage, fatigue-from-spell, or magical-effect-reduction state to restore." +} diff --git a/data/spells/sanctuary.json b/data/spells/sanctuary.json new file mode 100644 index 0000000..ca7095a --- /dev/null +++ b/data/spells/sanctuary.json @@ -0,0 +1,10 @@ +{ + "name": "Sanctuary", + "level": 1, + "school": "abjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "You ward a creature against attacks for 1 round/level: any opponent attempting to directly attack the warded creature must succeed on a Will save or be unable to follow through with that attack and cannot directly attack the subject for the duration. The subject cannot attack without ending the spell. Will negates. No combat effect encoded: the engine has no attack-refusal-by-save mechanic, so the protection cannot be represented." +} diff --git a/data/spells/saving_finale.json b/data/spells/saving_finale.json new file mode 100644 index 0000000..487d4f7 --- /dev/null +++ b/data/spells/saving_finale.json @@ -0,0 +1,10 @@ +{ + "name": "Saving Finale", + "level": 1, + "school": "evocation", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "Immediate action, requires an active bardic performance: when a creature affected by your performance fails a saving throw, end the performance to let it immediately reroll the failed save and take the new result. Will negates (harmless). No combat effect encoded: conditional save rerolls tied to bardic performance are not modeled by the engine." +} diff --git a/data/spells/scorching_ray.json b/data/spells/scorching_ray.json new file mode 100644 index 0000000..8716a5f --- /dev/null +++ b/data/spells/scorching_ray.json @@ -0,0 +1,17 @@ +{ + "name": "Scorching Ray", + "level": 2, + "school": "evocation", + "range": "close", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "4d6", + "types": ["fire"], + "half_on_save": false + } + ], + "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." +} diff --git a/data/spells/shard_of_chaos.json b/data/spells/shard_of_chaos.json new file mode 100644 index 0000000..0130ff8 --- /dev/null +++ b/data/spells/shard_of_chaos.json @@ -0,0 +1,10 @@ +{ + "name": "Shard of Chaos", + "level": 2, + "school": "evocation", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented." +} diff --git a/data/spells/shield.json b/data/spells/shield.json new file mode 100644 index 0000000..9870e1b --- /dev/null +++ b/data/spells/shield.json @@ -0,0 +1,18 @@ +{ + "name": "Shield", + "level": 1, + "school": "abjuration", + "range": "personal", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "buff", + "modifiers": [ + { "target": "ac", "value": 4, "bonus_type": "deflection" } + ], + "duration_rounds": 10 + } + ], + "description": "An invisible shield of force hovers in front of you, granting a +4 shield bonus to AC for 1 min./level (modeled as 10 rounds; the shield bonus type is approximated as deflection since the engine has no shield bonus type). Negating magic missile and the ranged-attack deflection are not modeled." +} diff --git a/data/spells/shield_of_faith.json b/data/spells/shield_of_faith.json new file mode 100644 index 0000000..45dae8b --- /dev/null +++ b/data/spells/shield_of_faith.json @@ -0,0 +1,10 @@ +{ + "name": "Shield of Faith", + "level": 1, + "school": "abjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "A shimmering magical field grants the target a +2 deflection bonus to AC (+1 per six caster levels, max +5) for 1 min./level. Will negates (harmless). Simplified: the engine has no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/shocking_grasp.json b/data/spells/shocking_grasp.json new file mode 100644 index 0000000..23f6839 --- /dev/null +++ b/data/spells/shocking_grasp.json @@ -0,0 +1,17 @@ +{ + "name": "Shocking Grasp", + "level": 1, + "school": "evocation", + "range": "touch", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "1d6", + "types": ["electricity"], + "half_on_save": false + } + ], + "description": "Your successful melee touch attack deals 1d6 points of electricity damage per caster level (maximum 5d6) — modeled at caster level 1 (1d6). No save; the +3 attack bonus against metal-armored foes is not modeled." +} diff --git a/data/spells/silence.json b/data/spells/silence.json new file mode 100644 index 0000000..d5c2b1d --- /dev/null +++ b/data/spells/silence.json @@ -0,0 +1,10 @@ +{ + "name": "Silence", + "level": 2, + "school": "illusion", + "range": "long", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "Casting time 1 round. A 20-ft.-radius emanation centered on a creature, object, or point in space: all sound is stopped in the area, spells with verbal components cannot be cast, and creatures inside are immune to sonic and language-dependent effects. Duration 1 round/level. Will negates (object). No combat effect encoded: the engine has no verbal component or sound propagation model." +} diff --git a/data/spells/sleep.json b/data/spells/sleep.json new file mode 100644 index 0000000..ab53ae2 --- /dev/null +++ b/data/spells/sleep.json @@ -0,0 +1,24 @@ +{ + "name": "Sleep", + "level": 1, + "school": "enchantment", + "range": "medium", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "area", + "radius_ft": 10, + "save_for_half": false, + "effects": [ + { + "type": "condition", + "condition_name": "asleep", + "duration_rounds": 10, + "save_negates": true + } + ] + } + ], + "description": "A magical slumber comes upon 4 HD of living creatures in a 10-ft.-radius burst (fewest HD first; the HD budget is not modeled). Sleeping creatures are helpless (modeled as the asleep condition, 1 min./level approximated as 10 rounds). Will negates. Constructs and undead are immune (not modeled)." +} diff --git a/data/spells/sound_burst.json b/data/spells/sound_burst.json new file mode 100644 index 0000000..5ab47df --- /dev/null +++ b/data/spells/sound_burst.json @@ -0,0 +1,30 @@ +{ + "name": "Sound Burst", + "level": 2, + "school": "evocation", + "range": "close", + "save": "fort", + "base_dc": 10, + "effects": [ + { + "type": "area", + "radius_ft": 10, + "save_for_half": true, + "effects": [ + { + "type": "damage", + "formula": "1d8", + "types": ["sonic"], + "half_on_save": true + }, + { + "type": "condition", + "condition_name": "stunned", + "duration_rounds": 1, + "save_negates": true + } + ] + } + ], + "description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save or be stunned for 1 round (Fortitude partial — modeled as save for half damage and negates the stun). Creatures that cannot hear take the damage but are not stunned (not modeled)." +} diff --git a/data/spells/spark.json b/data/spells/spark.json new file mode 100644 index 0000000..bb7861d --- /dev/null +++ b/data/spells/spark.json @@ -0,0 +1,10 @@ +{ + "name": "Spark", + "level": 0, + "school": "evocation", + "range": "close", + "save": "fort", + "base_dc": 10, + "effects": [], + "description": "A cantrip: you create a tiny spark that ignites one unattended Fine flammable object within close range, in any weather (works as flint and steel but much faster). Fortitude negates (object). No combat effect: the spell targets objects, not creatures." +} diff --git a/data/spells/spear_of_purity.json b/data/spells/spear_of_purity.json new file mode 100644 index 0000000..232482a --- /dev/null +++ b/data/spells/spear_of_purity.json @@ -0,0 +1,10 @@ +{ + "name": "Spear of Purity", + "level": 2, + "school": "evocation", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented." +} diff --git a/data/spells/spiritual_weapon.json b/data/spells/spiritual_weapon.json new file mode 100644 index 0000000..58bdebe --- /dev/null +++ b/data/spells/spiritual_weapon.json @@ -0,0 +1,10 @@ +{ + "name": "Spiritual Weapon", + "level": 2, + "school": "evocation", + "range": "medium", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "A force weapon appears and attacks foes at a distance as you direct it (1d8 force damage +1 per three caster levels), using your base attack bonus plus Wisdom modifier, once per round on your turn for 1 round/level. No combat effect encoded: the engine has no autonomous floating-attack creature to summon, and the weapon's per-round attacks are not modeled." +} diff --git a/data/spells/stabilize.json b/data/spells/stabilize.json new file mode 100644 index 0000000..e20eabb --- /dev/null +++ b/data/spells/stabilize.json @@ -0,0 +1,10 @@ +{ + "name": "Stabilize", + "level": 0, + "school": "conjuration", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "A cantrip: you touch a living creature with -1 or fewer hit points (up to close range) and stabilize it automatically, so it stops losing hit points until it takes more damage. Will negates (harmless). No combat effect encoded: the engine's dying/stabilization track is not exposed to the spell-effect system." +} diff --git a/data/spells/status.json b/data/spells/status.json new file mode 100644 index 0000000..6a77032 --- /dev/null +++ b/data/spells/status.json @@ -0,0 +1,10 @@ +{ + "name": "Status", + "level": 2, + "school": "divination", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "You mentally monitor the relative position and general condition (unharmed, wounded, staggered, unconscious, dying, poisoned, and so on) of up to one creature per three caster levels for 1 hour/level. Will negates (harmless). No combat effect: out-of-combat scrying is not modeled by the engine." +} diff --git a/data/spells/stave_off_corruption.json b/data/spells/stave_off_corruption.json new file mode 100644 index 0000000..df464c7 --- /dev/null +++ b/data/spells/stave_off_corruption.json @@ -0,0 +1,10 @@ +{ + "name": "Stave Off Corruption", + "level": 2, + "school": "abjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "You ward the target to slow the advancement of its corruption, granting a +2 circumstance bonus on saving throws against the advancement of that corruption for 1 day per 2 caster levels. Will negates (harmless). Simplified: the engine has no corruption track and no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/summon_instrument.json b/data/spells/summon_instrument.json new file mode 100644 index 0000000..c41261b --- /dev/null +++ b/data/spells/summon_instrument.json @@ -0,0 +1,10 @@ +{ + "name": "Summon Instrument", + "level": 0, + "school": "conjuration", + "range": "personal", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "A cantrip (casting time 1 round): you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). No combat effect: summoned objects are not modeled by the engine." +} diff --git a/data/spells/summon_monster_i.json b/data/spells/summon_monster_i.json new file mode 100644 index 0000000..e957929 --- /dev/null +++ b/data/spells/summon_monster_i.json @@ -0,0 +1,16 @@ +{ + "name": "Summon Monster I", + "level": 1, + "school": "conjuration", + "range": "close", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "summon", + "creature": "data/monsters/celestial_wolf.json", + "duration_rounds": 4 + } + ], + "description": "Casting time 1 round: summons one extraplanar creature from the 1st-level list (e.g. dire rat, dog, eagle) to fight for you for 1 round/level. Simplified: the engine bestiary holds no 1st-level summon, so the celestial wolf model is reused and the duration is set to 4 rounds (1 round/level at caster level 4)." +} diff --git a/data/spells/suppress_charms_and_compulsions.json b/data/spells/suppress_charms_and_compulsions.json new file mode 100644 index 0000000..845b711 --- /dev/null +++ b/data/spells/suppress_charms_and_compulsions.json @@ -0,0 +1,10 @@ +{ + "name": "Suppress Charms and Compulsions", + "level": 2, + "school": "abjuration", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "You bolster the target's willpower: either a +4 morale bonus on saving throws against charm and compulsion effects for 10 minutes, or (as concentration, max 1 round/level) suppression of all charm and compulsion effects on the targets. Will negates (harmless). Simplified: the engine has no conditional save bonus and no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/surmount_affliction.json b/data/spells/surmount_affliction.json new file mode 100644 index 0000000..dc2533b --- /dev/null +++ b/data/spells/surmount_affliction.json @@ -0,0 +1,10 @@ +{ + "name": "Surmount Affliction", + "level": 2, + "school": "abjuration", + "range": "personal", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "You temporarily overcome one harmful condition on yourself (blinded, confused, dazed, dazzled, deafened, fatigued, frightened, paralyzed, shaken, or sickened) for 1 round/level — the condition's effect is suspended, not ended. No combat effect encoded: the engine has no condition-suspension mechanic." +} diff --git a/data/spells/tears_to_wine.json b/data/spells/tears_to_wine.json new file mode 100644 index 0000000..ab26ecb --- /dev/null +++ b/data/spells/tears_to_wine.json @@ -0,0 +1,10 @@ +{ + "name": "Tears to Wine", + "level": 2, + "school": "transmutation", + "range": "close", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "You transmute up to 1 cubic foot of nonmagical liquid per two caster levels (including spoiled, poisoned, or contaminated drink, tears, seawater) into mead or wine of average quality for 10 min./level (RAW range 10 ft., approximated as close). Will negates (object). No combat effect: liquid transmutation is not modeled by the engine." +} diff --git a/data/spells/touch_of_fatigue.json b/data/spells/touch_of_fatigue.json new file mode 100644 index 0000000..fde2650 --- /dev/null +++ b/data/spells/touch_of_fatigue.json @@ -0,0 +1,17 @@ +{ + "name": "Touch of Fatigue", + "level": 0, + "school": "necromancy", + "range": "touch", + "save": "fort", + "base_dc": 10, + "effects": [ + { + "type": "condition", + "condition_name": "fatigued", + "duration_rounds": 4, + "save_negates": true + } + ], + "description": "A cantrip: a melee touch attack channels negative energy, making the target fatigued for 1 round/level (modeled as 4 rounds at caster level 4; the effect ends with the duration rather than by long rest). No effect on an already fatigued creature or on creatures immune to fatigue. Fortitude negates." +} diff --git a/data/spells/unseen_servant.json b/data/spells/unseen_servant.json new file mode 100644 index 0000000..b706b54 --- /dev/null +++ b/data/spells/unseen_servant.json @@ -0,0 +1,10 @@ +{ + "name": "Unseen Servant", + "level": 1, + "school": "conjuration", + "range": "close", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "An invisible, mindless, shapeless force performs simple tasks at your command for 1 hour/level: fetching, opening unstuck doors, holding chairs, cleaning or mending (effective Strength 2, speed 15 ft., no attacks, DC 10 skill limit). No combat effect: object manipulation by invisible servants is not modeled by the engine." +} diff --git a/data/spells/vanish.json b/data/spells/vanish.json new file mode 100644 index 0000000..6ce37ad --- /dev/null +++ b/data/spells/vanish.json @@ -0,0 +1,17 @@ +{ + "name": "Vanish", + "level": 1, + "school": "illusion", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "condition", + "condition_name": "invisible", + "duration_rounds": 5, + "save_negates": false + } + ], + "description": "As invisibility, but only for 1 round/level (maximum 5 rounds) — the spell ends if the subject attacks. Will negates (harmless) — modeled as always accepted by the target. Modeled as the invisible condition (total concealment for the target; the attack-ends-spell rule is not modeled)." +} diff --git a/data/spells/virtue.json b/data/spells/virtue.json new file mode 100644 index 0000000..6884604 --- /dev/null +++ b/data/spells/virtue.json @@ -0,0 +1,10 @@ +{ + "name": "Virtue", + "level": 0, + "school": "transmutation", + "range": "touch", + "save": "none", + "base_dc": 10, + "effects": [], + "description": "A cantrip: with a touch you infuse a creature with a tiny surge of life, granting it 1 temporary hit point for 1 minute. Spell resistance yes (harmless). Simplified: the engine has no temporary hit points and no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/weapon_of_awe.json b/data/spells/weapon_of_awe.json new file mode 100644 index 0000000..6696065 --- /dev/null +++ b/data/spells/weapon_of_awe.json @@ -0,0 +1,10 @@ +{ + "name": "Weapon of Awe", + "level": 2, + "school": "transmutation", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [], + "description": "The touched weapon becomes an awe-inspiring instrument for 1 min./level: it gains a +2 sacred bonus on damage rolls, and a creature struck by a critical hit from it becomes shaken for 1 round (a mind-affecting fear effect). Will negates (harmless, object). Simplified: the engine has no weapon-targeted buff track and no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied." +} diff --git a/data/spells/web.json b/data/spells/web.json new file mode 100644 index 0000000..3d80f16 --- /dev/null +++ b/data/spells/web.json @@ -0,0 +1,24 @@ +{ + "name": "Web", + "level": 2, + "school": "conjuration", + "range": "medium", + "save": "ref", + "base_dc": 10, + "effects": [ + { + "type": "area", + "radius_ft": 20, + "save_for_half": false, + "effects": [ + { + "type": "condition", + "condition_name": "grappled", + "duration_rounds": 2, + "save_negates": true + } + ] + } + ], + "description": "You conjure a many-layered mass of sticky strands filling a 20-ft.-radius spread (modeled as a 20-ft. burst). Creatures caught in the area are grappled by the fibers (Reflex negates) until they break free; the web also makes the area difficult terrain and provides cover (not modeled). Duration 10 min./level approximated as 2 rounds of the grappled condition." +} diff --git a/tests/test_runner_spells.py b/tests/test_runner_spells.py index d684255..e7ae480 100644 --- a/tests/test_runner_spells.py +++ b/tests/test_runner_spells.py @@ -24,11 +24,11 @@ MAP = ROOT / "data" / "maps" / "sample_arena.yaml" ONI = ROOT / "data" / "pcs" / "oni.json" GOBLIN = ROOT / "data" / "monsters" / "goblin.json" -_NUM_SPELLS = 75 +_NUM_SPELLS = 107 def test_runner_loads_realtime_spell_registry() -> None: - """The runner's registry source holds all 75 real spells from data/spells/.""" + """The runner's registry source holds all 107 real spells from data/spells/.""" registry = load_spell_registry(SPELLS_DIR) assert len(registry) == _NUM_SPELLS diff --git a/tests/test_spell_registry_batch2.py b/tests/test_spell_registry_batch2.py index 1073de8..4cfbacc 100644 --- a/tests/test_spell_registry_batch2.py +++ b/tests/test_spell_registry_batch2.py @@ -115,7 +115,8 @@ class TestBatch2Loading: def test_registry_grew_by_exactly_32(self) -> None: reg = load_spell_registry(_SPELLS_DIR) - assert len(reg) == _BATCH1_COUNT + 32 + # 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: diff --git a/tests/test_spell_registry_batch3.py b/tests/test_spell_registry_batch3.py new file mode 100644 index 0000000..00d10a6 --- /dev/null +++ b/tests/test_spell_registry_batch3.py @@ -0,0 +1,146 @@ +"""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 diff --git a/tests/test_spells.py b/tests/test_spells.py index 30c3c8e..f128598 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -123,13 +123,13 @@ def _make_engine( class TestSpellLoading: - """Given: data/spells/ directory with 75 JSON spell files + """Given: data/spells/ directory with 107 JSON spell files When: load_spell_registry is called - Then: all 75 spells are loaded with correct fields.""" + Then: all 107 spells are loaded with correct fields.""" def test_loads_all_10_spells(self) -> None: reg = load_spell_registry(_SPELLS_DIR) - assert len(reg) == 75 + assert len(reg) == 107 def test_spell_names_present(self) -> None: reg = load_spell_registry(_SPELLS_DIR) @@ -140,7 +140,7 @@ class TestSpellLoading: "Summon Monster II", } assert expected <= set(reg) - assert len(reg) == 75 + assert len(reg) == 107 def test_fireball_fields(self) -> None: spell = load_spell(_SPELLS_DIR / "fireball.json")