Add feature roadmap and implementation plan for D&D spell extraction and validation upgrades

This commit is contained in:
2026-07-20 13:47:43 -05:00
parent 385e4593f4
commit ac53f83ac8
10 changed files with 1355 additions and 490 deletions

View File

@@ -0,0 +1,25 @@
# D&D 5e 2014 SRD spell catalog sources
The canonical catalog in `assets/dnd_5e_2014_srd_spells.json` was consolidated
from the eight class spell lists in the System Reference Document 5.1. Repeated
class-list entries were combined into one spell record while retaining spell
level and class membership. The consolidation produced 319 unique spells and
779 class memberships.
Source: [System Reference Document 5.1](https://dnd.wizards.com/resources/systems-reference-document)
License: [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/)
Required attribution:
> This work includes material taken from the System Reference Document 5.1
> (“SRD 5.1”) by Wizards of the Coast LLC and available at
> https://dnd.wizards.com/resources/systems-reference-document. The SRD 5.1 is
> licensed under the Creative Commons Attribution 4.0 International License
> available at https://creativecommons.org/licenses/by/4.0/legalcode.
The catalog contains names, spell levels, and class-list membership only. It
does not reproduce spell descriptions. Canonical spell names retain the
punctuation used by the source lists. The package lookup normalizes common
apostrophe variants, case, and repeated whitespace without changing those
display names.

View File

@@ -0,0 +1,331 @@
{
"id": "dnd-5e-2014-srd-spells",
"ruleset": "dnd-5e-2014",
"source": {
"title": "System Reference Document 5.1",
"version": "5.1",
"url": "https://dnd.wizards.com/resources/systems-reference-document",
"license": "CC-BY-4.0"
},
"spells": [
{"name":"Acid Arrow","level":2,"classes":["wizard"],"aliases":[]},
{"name":"Acid Splash","level":0,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Aid","level":2,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Alarm","level":1,"classes":["ranger","wizard"],"aliases":[]},
{"name":"Alter Self","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Animal Friendship","level":1,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Animal Messenger","level":2,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Animal Shapes","level":8,"classes":["druid"],"aliases":[]},
{"name":"Animate Dead","level":3,"classes":["cleric","wizard"],"aliases":[]},
{"name":"Animate Objects","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Antilife Shell","level":5,"classes":["druid"],"aliases":[]},
{"name":"Antimagic Field","level":8,"classes":["cleric","wizard"],"aliases":[]},
{"name":"Antipathy/Sympathy","level":8,"classes":["druid","wizard"],"aliases":[]},
{"name":"Arcane Eye","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Arcane Hand","level":5,"classes":["wizard"],"aliases":[]},
{"name":"Arcane Lock","level":2,"classes":["wizard"],"aliases":[]},
{"name":"Arcane Sword","level":7,"classes":["bard","wizard"],"aliases":[]},
{"name":"Arcanists Magic Aura","level":2,"classes":["wizard"],"aliases":[]},
{"name":"Astral Projection","level":9,"classes":["cleric","warlock","wizard"],"aliases":[]},
{"name":"Augury","level":2,"classes":["cleric"],"aliases":[]},
{"name":"Awaken","level":5,"classes":["bard","druid"],"aliases":[]},
{"name":"Bane","level":1,"classes":["bard","cleric"],"aliases":[]},
{"name":"Banishment","level":4,"classes":["cleric","paladin","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Barkskin","level":2,"classes":["druid","ranger"],"aliases":[]},
{"name":"Beacon of Hope","level":3,"classes":["cleric"],"aliases":[]},
{"name":"Bestow Curse","level":3,"classes":["bard","cleric","wizard"],"aliases":[]},
{"name":"Black Tentacles","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Blade Barrier","level":6,"classes":["cleric"],"aliases":[]},
{"name":"Bless","level":1,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Blight","level":4,"classes":["druid","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Blindness/Deafness","level":2,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]},
{"name":"Blink","level":3,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Blur","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Branding Smite","level":2,"classes":["paladin"],"aliases":[]},
{"name":"Burning Hands","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Call Lightning","level":3,"classes":["druid"],"aliases":[]},
{"name":"Calm Emotions","level":2,"classes":["bard","cleric"],"aliases":[]},
{"name":"Chain Lightning","level":6,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Charm Person","level":1,"classes":["bard","druid","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Chill Touch","level":0,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Circle of Death","level":6,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Clairvoyance","level":3,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]},
{"name":"Clone","level":8,"classes":["wizard"],"aliases":[]},
{"name":"Cloudkill","level":5,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Color Spray","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Command","level":1,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Commune","level":5,"classes":["cleric"],"aliases":[]},
{"name":"Commune with Nature","level":5,"classes":["druid","ranger"],"aliases":[]},
{"name":"Comprehend Languages","level":1,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Compulsion","level":4,"classes":["bard"],"aliases":[]},
{"name":"Cone of Cold","level":5,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Confusion","level":4,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]},
{"name":"Conjure Animals","level":3,"classes":["druid","ranger"],"aliases":[]},
{"name":"Conjure Celestial","level":7,"classes":["cleric"],"aliases":[]},
{"name":"Conjure Elemental","level":5,"classes":["druid","wizard"],"aliases":[]},
{"name":"Conjure Fey","level":6,"classes":["druid","warlock"],"aliases":[]},
{"name":"Conjure Minor Elementals","level":4,"classes":["druid","wizard"],"aliases":[]},
{"name":"Conjure Woodland Beings","level":4,"classes":["druid","ranger"],"aliases":[]},
{"name":"Contact Other Plane","level":5,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Contagion","level":5,"classes":["cleric","druid"],"aliases":[]},
{"name":"Contingency","level":6,"classes":["wizard"],"aliases":[]},
{"name":"Continual Flame","level":2,"classes":["cleric","wizard"],"aliases":[]},
{"name":"Control Water","level":4,"classes":["cleric","druid","wizard"],"aliases":[]},
{"name":"Control Weather","level":8,"classes":["cleric","druid","wizard"],"aliases":[]},
{"name":"Counterspell","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Create Food and Water","level":3,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Create Undead","level":6,"classes":["cleric","warlock","wizard"],"aliases":[]},
{"name":"Create or Destroy Water","level":1,"classes":["cleric","druid"],"aliases":[]},
{"name":"Creation","level":5,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Cure Wounds","level":1,"classes":["bard","cleric","druid","paladin","ranger"],"aliases":[]},
{"name":"Dancing Lights","level":0,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Darkness","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Darkvision","level":2,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Daylight","level":3,"classes":["cleric","druid","paladin","ranger","sorcerer"],"aliases":[]},
{"name":"Death Ward","level":4,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Delayed Blast Fireball","level":7,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Demiplane","level":8,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Detect Evil and Good","level":1,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Detect Magic","level":1,"classes":["bard","cleric","druid","paladin","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Detect Poison and Disease","level":1,"classes":["cleric","druid","paladin","ranger"],"aliases":[]},
{"name":"Detect Thoughts","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Dimension Door","level":4,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Disguise Self","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Disintegrate","level":6,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Dispel Evil and Good","level":5,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Dispel Magic","level":3,"classes":["bard","cleric","druid","paladin","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Divination","level":4,"classes":["cleric"],"aliases":[]},
{"name":"Divine Favor","level":1,"classes":["paladin"],"aliases":[]},
{"name":"Divine Word","level":7,"classes":["cleric"],"aliases":[]},
{"name":"Dominate Beast","level":4,"classes":["druid","sorcerer"],"aliases":[]},
{"name":"Dominate Monster","level":8,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Dominate Person","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Dream","level":5,"classes":["bard","warlock","wizard"],"aliases":[]},
{"name":"Druidcraft","level":0,"classes":["druid"],"aliases":[]},
{"name":"Earthquake","level":8,"classes":["cleric","druid","sorcerer"],"aliases":[]},
{"name":"Eldritch Blast","level":0,"classes":["warlock"],"aliases":[]},
{"name":"Enhance Ability","level":2,"classes":["bard","cleric","druid","sorcerer"],"aliases":[]},
{"name":"Enlarge/Reduce","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Entangle","level":1,"classes":["druid"],"aliases":[]},
{"name":"Enthrall","level":2,"classes":["bard","warlock"],"aliases":[]},
{"name":"Etherealness","level":7,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Expeditious Retreat","level":1,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Eyebite","level":6,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Fabricate","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Faerie Fire","level":1,"classes":["bard","druid"],"aliases":[]},
{"name":"Faithful Hound","level":4,"classes":["wizard"],"aliases":[]},
{"name":"False Life","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Fear","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Feather Fall","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Feeblemind","level":8,"classes":["bard","druid","warlock","wizard"],"aliases":[]},
{"name":"Find Familiar","level":1,"classes":["wizard"],"aliases":[]},
{"name":"Find Steed","level":2,"classes":["paladin"],"aliases":[]},
{"name":"Find Traps","level":2,"classes":["cleric","druid","ranger"],"aliases":[]},
{"name":"Find the Path","level":6,"classes":["bard","cleric","druid"],"aliases":[]},
{"name":"Finger of Death","level":7,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Fire Bolt","level":0,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Fire Shield","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Fire Storm","level":7,"classes":["cleric","druid","sorcerer"],"aliases":[]},
{"name":"Fireball","level":3,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Flame Blade","level":2,"classes":["druid"],"aliases":[]},
{"name":"Flame Strike","level":5,"classes":["cleric"],"aliases":[]},
{"name":"Flaming Sphere","level":2,"classes":["druid","wizard"],"aliases":[]},
{"name":"Flesh to Stone","level":6,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Floating Disk","level":1,"classes":["wizard"],"aliases":[]},
{"name":"Fly","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Fog Cloud","level":1,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Forbiddance","level":6,"classes":["cleric"],"aliases":[]},
{"name":"Forcecage","level":7,"classes":["bard","warlock","wizard"],"aliases":[]},
{"name":"Foresight","level":9,"classes":["bard","druid","warlock","wizard"],"aliases":[]},
{"name":"Freedom of Movement","level":4,"classes":["bard","cleric","druid","ranger"],"aliases":[]},
{"name":"Freezing Sphere","level":6,"classes":["wizard"],"aliases":[]},
{"name":"Gaseous Form","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Gate","level":9,"classes":["cleric","sorcerer","wizard"],"aliases":[]},
{"name":"Geas","level":5,"classes":["bard","cleric","druid","paladin","wizard"],"aliases":[]},
{"name":"Gentle Repose","level":2,"classes":["cleric","wizard"],"aliases":[]},
{"name":"Giant Insect","level":4,"classes":["druid"],"aliases":[]},
{"name":"Glibness","level":8,"classes":["bard","warlock"],"aliases":[]},
{"name":"Globe of Invulnerability","level":6,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Glyph of Warding","level":3,"classes":["bard","cleric","wizard"],"aliases":[]},
{"name":"Goodberry","level":1,"classes":["druid","ranger"],"aliases":[]},
{"name":"Grease","level":1,"classes":["wizard"],"aliases":[]},
{"name":"Greater Invisibility","level":4,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Greater Restoration","level":5,"classes":["bard","cleric","druid"],"aliases":[]},
{"name":"Guardian of Faith","level":4,"classes":["cleric"],"aliases":[]},
{"name":"Guards and Wards","level":6,"classes":["bard","wizard"],"aliases":[]},
{"name":"Guidance","level":0,"classes":["cleric","druid"],"aliases":[]},
{"name":"Guiding Bolt","level":1,"classes":["cleric"],"aliases":[]},
{"name":"Gust of Wind","level":2,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Hallow","level":5,"classes":["cleric"],"aliases":[]},
{"name":"Hallucinatory Terrain","level":4,"classes":["bard","druid","warlock","wizard"],"aliases":[]},
{"name":"Harm","level":6,"classes":["cleric"],"aliases":[]},
{"name":"Haste","level":3,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Heal","level":6,"classes":["cleric","druid"],"aliases":[]},
{"name":"Healing Word","level":1,"classes":["bard","cleric","druid"],"aliases":[]},
{"name":"Heat Metal","level":2,"classes":["bard","druid"],"aliases":[]},
{"name":"Hellish Rebuke","level":1,"classes":["warlock"],"aliases":[]},
{"name":"Heroes Feast","level":6,"classes":["cleric","druid"],"aliases":[]},
{"name":"Heroism","level":1,"classes":["bard","paladin"],"aliases":[]},
{"name":"Hideous Laughter","level":1,"classes":["bard","wizard"],"aliases":[]},
{"name":"Hold Monster","level":5,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Hold Person","level":2,"classes":["bard","cleric","druid","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Holy Aura","level":8,"classes":["cleric"],"aliases":[]},
{"name":"Hunters Mark","level":1,"classes":["ranger"],"aliases":[]},
{"name":"Hypnotic Pattern","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Ice Storm","level":4,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Identify","level":1,"classes":["bard","wizard"],"aliases":[]},
{"name":"Illusory Script","level":1,"classes":["bard","warlock","wizard"],"aliases":[]},
{"name":"Imprisonment","level":9,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Incendiary Cloud","level":8,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Inflict Wounds","level":1,"classes":["cleric"],"aliases":[]},
{"name":"Insect Plague","level":5,"classes":["cleric","druid","sorcerer"],"aliases":[]},
{"name":"Instant Summons","level":6,"classes":["wizard"],"aliases":[]},
{"name":"Invisibility","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Irresistible Dance","level":6,"classes":["bard","wizard"],"aliases":[]},
{"name":"Jump","level":1,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Knock","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Legend Lore","level":5,"classes":["bard","cleric","wizard"],"aliases":[]},
{"name":"Lesser Restoration","level":2,"classes":["bard","cleric","druid","paladin","ranger"],"aliases":[]},
{"name":"Levitate","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Light","level":0,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]},
{"name":"Lightning Bolt","level":3,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Locate Animals or Plants","level":2,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Locate Creature","level":4,"classes":["bard","cleric","druid","paladin","ranger","wizard"],"aliases":[]},
{"name":"Locate Object","level":2,"classes":["bard","cleric","druid","paladin","ranger","wizard"],"aliases":[]},
{"name":"Longstrider","level":1,"classes":["bard","druid","ranger","wizard"],"aliases":[]},
{"name":"Mage Armor","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Mage Hand","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Magic Circle","level":3,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]},
{"name":"Magic Jar","level":6,"classes":["wizard"],"aliases":[]},
{"name":"Magic Missile","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Magic Mouth","level":2,"classes":["bard","wizard"],"aliases":[]},
{"name":"Magic Weapon","level":2,"classes":["paladin","wizard"],"aliases":[]},
{"name":"Magnificent Mansion","level":7,"classes":["bard","wizard"],"aliases":[]},
{"name":"Major Image","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Mass Cure Wounds","level":5,"classes":["bard","cleric","druid"],"aliases":[]},
{"name":"Mass Heal","level":9,"classes":["cleric"],"aliases":[]},
{"name":"Mass Healing Word","level":3,"classes":["cleric"],"aliases":[]},
{"name":"Mass Suggestion","level":6,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Maze","level":8,"classes":["wizard"],"aliases":[]},
{"name":"Meld into Stone","level":3,"classes":["cleric","druid"],"aliases":[]},
{"name":"Mending","level":0,"classes":["bard","cleric","druid","sorcerer","wizard"],"aliases":[]},
{"name":"Message","level":0,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Meteor Swarm","level":9,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Mind Blank","level":8,"classes":["bard","wizard"],"aliases":[]},
{"name":"Minor Illusion","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Mirage Arcane","level":7,"classes":["bard","druid","wizard"],"aliases":[]},
{"name":"Mirror Image","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Mislead","level":5,"classes":["bard","wizard"],"aliases":[]},
{"name":"Misty Step","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Modify Memory","level":5,"classes":["bard","wizard"],"aliases":[]},
{"name":"Moonbeam","level":2,"classes":["druid"],"aliases":[]},
{"name":"Move Earth","level":6,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Nondetection","level":3,"classes":["bard","ranger","wizard"],"aliases":[]},
{"name":"Pass without Trace","level":2,"classes":["druid","ranger"],"aliases":[]},
{"name":"Passwall","level":5,"classes":["wizard"],"aliases":[]},
{"name":"Phantasmal Killer","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Phantom Steed","level":3,"classes":["wizard"],"aliases":[]},
{"name":"Planar Ally","level":6,"classes":["cleric"],"aliases":[]},
{"name":"Planar Binding","level":5,"classes":["bard","cleric","druid","wizard"],"aliases":[]},
{"name":"Plane Shift","level":7,"classes":["cleric","druid","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Plant Growth","level":3,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Poison Spray","level":0,"classes":["druid","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Polymorph","level":4,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]},
{"name":"Power Word Kill","level":9,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Power Word Stun","level":8,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Prayer of Healing","level":2,"classes":["cleric"],"aliases":[]},
{"name":"Prestidigitation","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Prismatic Spray","level":7,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Prismatic Wall","level":9,"classes":["wizard"],"aliases":[]},
{"name":"Private Sanctum","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Produce Flame","level":0,"classes":["druid"],"aliases":[]},
{"name":"Programmed Illusion","level":6,"classes":["bard","wizard"],"aliases":[]},
{"name":"Project Image","level":7,"classes":["bard","wizard"],"aliases":[]},
{"name":"Protection from Energy","level":3,"classes":["cleric","druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Protection from Evil and Good","level":1,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]},
{"name":"Protection from Poison","level":2,"classes":["cleric","druid","paladin","ranger"],"aliases":[]},
{"name":"Purify Food and Drink","level":1,"classes":["cleric","druid","paladin"],"aliases":[]},
{"name":"Raise Dead","level":5,"classes":["bard","cleric","paladin"],"aliases":[]},
{"name":"Ray of Enfeeblement","level":2,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Ray of Frost","level":0,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Regenerate","level":7,"classes":["bard","cleric","druid"],"aliases":[]},
{"name":"Reincarnate","level":5,"classes":["druid"],"aliases":[]},
{"name":"Remove Curse","level":3,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]},
{"name":"Resilient Sphere","level":4,"classes":["wizard"],"aliases":[]},
{"name":"Resistance","level":0,"classes":["cleric","druid"],"aliases":[]},
{"name":"Resurrection","level":7,"classes":["bard","cleric"],"aliases":[]},
{"name":"Reverse Gravity","level":7,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Revivify","level":3,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Rope Trick","level":2,"classes":["wizard"],"aliases":[]},
{"name":"Sacred Flame","level":0,"classes":["cleric"],"aliases":[]},
{"name":"Sanctuary","level":1,"classes":["cleric"],"aliases":[]},
{"name":"Scorching Ray","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Scrying","level":5,"classes":["bard","cleric","druid","warlock","wizard"],"aliases":[]},
{"name":"Secret Chest","level":4,"classes":["wizard"],"aliases":[]},
{"name":"See Invisibility","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Seeming","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Sending","level":3,"classes":["bard","cleric","wizard"],"aliases":[]},
{"name":"Sequester","level":7,"classes":["wizard"],"aliases":[]},
{"name":"Shapechange","level":9,"classes":["druid","wizard"],"aliases":[]},
{"name":"Shatter","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Shield","level":1,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Shield of Faith","level":1,"classes":["cleric","paladin"],"aliases":[]},
{"name":"Shillelagh","level":0,"classes":["druid"],"aliases":[]},
{"name":"Shocking Grasp","level":0,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Silence","level":2,"classes":["bard","cleric","ranger"],"aliases":[]},
{"name":"Silent Image","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Simulacrum","level":7,"classes":["wizard"],"aliases":[]},
{"name":"Sleep","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Sleet Storm","level":3,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Slow","level":3,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Spare the Dying","level":0,"classes":["cleric"],"aliases":[]},
{"name":"Speak with Animals","level":1,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Speak with Dead","level":3,"classes":["bard","cleric"],"aliases":[]},
{"name":"Speak with Plants","level":3,"classes":["bard","druid","ranger"],"aliases":[]},
{"name":"Spider Climb","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Spike Growth","level":2,"classes":["druid","ranger"],"aliases":[]},
{"name":"Spirit Guardians","level":3,"classes":["cleric"],"aliases":[]},
{"name":"Spiritual Weapon","level":2,"classes":["cleric"],"aliases":[]},
{"name":"Stinking Cloud","level":3,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Stone Shape","level":4,"classes":["cleric","druid","wizard"],"aliases":[]},
{"name":"Stoneskin","level":4,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Storm of Vengeance","level":9,"classes":["druid"],"aliases":[]},
{"name":"Suggestion","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Sunbeam","level":6,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Sunburst","level":8,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Symbol","level":7,"classes":["bard","cleric","wizard"],"aliases":[]},
{"name":"Telekinesis","level":5,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Telepathic Bond","level":5,"classes":["wizard"],"aliases":[]},
{"name":"Teleport","level":7,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]},
{"name":"Teleportation Circle","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]},
{"name":"Thaumaturgy","level":0,"classes":["cleric"],"aliases":[]},
{"name":"Thunderwave","level":1,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]},
{"name":"Time Stop","level":9,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Tiny Hut","level":3,"classes":["bard","wizard"],"aliases":[]},
{"name":"Tongues","level":3,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Transport via Plants","level":6,"classes":["druid"],"aliases":[]},
{"name":"Tree Stride","level":5,"classes":["druid","ranger"],"aliases":[]},
{"name":"True Polymorph","level":9,"classes":["bard","warlock","wizard"],"aliases":[]},
{"name":"True Resurrection","level":9,"classes":["cleric","druid"],"aliases":[]},
{"name":"True Seeing","level":6,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"True Strike","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]},
{"name":"Unseen Servant","level":1,"classes":["bard","warlock","wizard"],"aliases":[]},
{"name":"Vampiric Touch","level":3,"classes":["warlock","wizard"],"aliases":[]},
{"name":"Vicious Mockery","level":0,"classes":["bard"],"aliases":[]},
{"name":"Wall of Fire","level":4,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Wall of Force","level":5,"classes":["wizard"],"aliases":[]},
{"name":"Wall of Ice","level":6,"classes":["wizard"],"aliases":[]},
{"name":"Wall of Stone","level":5,"classes":["druid","sorcerer","wizard"],"aliases":[]},
{"name":"Wall of Thorns","level":6,"classes":["druid"],"aliases":[]},
{"name":"Warding Bond","level":2,"classes":["cleric"],"aliases":[]},
{"name":"Water Breathing","level":3,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]},
{"name":"Water Walk","level":3,"classes":["cleric","druid","ranger","sorcerer"],"aliases":[]},
{"name":"Web","level":2,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Weird","level":9,"classes":["wizard"],"aliases":[]},
{"name":"Wind Walk","level":6,"classes":["druid"],"aliases":[]},
{"name":"Wind Wall","level":3,"classes":["druid","ranger"],"aliases":[]},
{"name":"Wish","level":9,"classes":["sorcerer","wizard"],"aliases":[]},
{"name":"Word of Recall","level":6,"classes":["cleric"],"aliases":[]},
{"name":"Zone of Truth","level":2,"classes":["bard","cleric","paladin"],"aliases":[]}
]
}

View File

@@ -0,0 +1,214 @@
// Package catalog provides immutable D&D spell reference catalogs.
package catalog
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"sync"
)
const (
SRD5E2014ID = "dnd-5e-2014-srd-spells"
SRD5E2014Ruleset = "dnd-5e-2014"
)
//go:embed assets/dnd_5e_2014_srd_spells.json
var catalogAssets embed.FS
type Source struct {
Title string `json:"title"`
Version string `json:"version"`
URL string `json:"url"`
License string `json:"license"`
}
type Spell struct {
Name string `json:"name"`
Level int `json:"level"`
Classes []string `json:"classes"`
Aliases []string `json:"aliases"`
}
type document struct {
ID string `json:"id"`
Ruleset string `json:"ruleset"`
Source Source `json:"source"`
Spells []Spell `json:"spells"`
}
type Catalog struct {
id string
ruleset string
source Source
spells []Spell
lookup map[string]int
}
var (
loadSRD5E2014Once sync.Once
loadedSRD5E2014 Catalog
loadSRD5E2014Err error
)
// LoadSRD5E2014 returns the embedded SRD 5.1 spell catalog. Returned values do
// not expose mutable catalog storage.
func LoadSRD5E2014() (Catalog, error) {
loadSRD5E2014Once.Do(func() {
loadedSRD5E2014, loadSRD5E2014Err = load("assets/dnd_5e_2014_srd_spells.json")
})
return loadedSRD5E2014, loadSRD5E2014Err
}
func (c Catalog) ID() string { return c.id }
func (c Catalog) Ruleset() string { return c.ruleset }
func (c Catalog) Source() Source { return c.source }
func (c Catalog) Spells() []Spell {
return cloneSpells(c.spells)
}
// Lookup matches canonical names and declared aliases after case, whitespace,
// and common apostrophe variants are normalized.
func (c Catalog) Lookup(name string) (Spell, bool) {
index, ok := c.lookup[lookupKey(name)]
if !ok {
return Spell{}, false
}
return cloneSpell(c.spells[index]), true
}
func load(path string) (Catalog, error) {
raw, err := catalogAssets.ReadFile(path)
if err != nil {
return Catalog{}, fmt.Errorf("read spell catalog: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var doc document
if err := decoder.Decode(&doc); err != nil {
return Catalog{}, fmt.Errorf("decode spell catalog: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return Catalog{}, fmt.Errorf("decode spell catalog: multiple JSON values")
}
return newCatalog(doc)
}
func newCatalog(doc document) (Catalog, error) {
if doc.ID != SRD5E2014ID {
return Catalog{}, fmt.Errorf("spell catalog id %q does not match %q", doc.ID, SRD5E2014ID)
}
if doc.Ruleset != SRD5E2014Ruleset {
return Catalog{}, fmt.Errorf("spell catalog ruleset %q does not match %q", doc.Ruleset, SRD5E2014Ruleset)
}
if strings.TrimSpace(doc.Source.Title) == "" || strings.TrimSpace(doc.Source.Version) == "" ||
strings.TrimSpace(doc.Source.URL) == "" || strings.TrimSpace(doc.Source.License) == "" {
return Catalog{}, fmt.Errorf("spell catalog source metadata must be complete")
}
if len(doc.Spells) == 0 {
return Catalog{}, fmt.Errorf("spell catalog spells must not be empty")
}
allowedClasses := map[string]struct{}{
"bard": {}, "cleric": {}, "druid": {}, "paladin": {},
"ranger": {}, "sorcerer": {}, "warlock": {}, "wizard": {},
}
lookup := make(map[string]int, len(doc.Spells))
for index := range doc.Spells {
spell := &doc.Spells[index]
if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" {
return Catalog{}, fmt.Errorf("spell[%d] name must be non-empty and trimmed", index)
}
if index > 0 && doc.Spells[index-1].Name >= spell.Name {
return Catalog{}, fmt.Errorf("spell catalog names must be unique and sorted")
}
if spell.Level < 0 || spell.Level > 9 {
return Catalog{}, fmt.Errorf("spell %q level must be between 0 and 9", spell.Name)
}
if len(spell.Classes) == 0 {
return Catalog{}, fmt.Errorf("spell %q classes must not be empty", spell.Name)
}
if err := validateSortedStrings(spell.Classes, func(value string) bool {
_, ok := allowedClasses[value]
return ok
}); err != nil {
return Catalog{}, fmt.Errorf("spell %q classes: %w", spell.Name, err)
}
if spell.Aliases == nil {
return Catalog{}, fmt.Errorf("spell %q aliases must be present", spell.Name)
}
if err := validateSortedStrings(spell.Aliases, func(value string) bool {
return value == strings.TrimSpace(value) && value != ""
}); err != nil {
return Catalog{}, fmt.Errorf("spell %q aliases: %w", spell.Name, err)
}
for _, candidate := range append([]string{spell.Name}, spell.Aliases...) {
key := lookupKey(candidate)
if key == "" {
return Catalog{}, fmt.Errorf("spell %q has an empty lookup key", spell.Name)
}
if previous, exists := lookup[key]; exists {
return Catalog{}, fmt.Errorf("spell %q lookup key conflicts with %q", spell.Name, doc.Spells[previous].Name)
}
lookup[key] = index
}
}
return Catalog{
id: doc.ID,
ruleset: doc.Ruleset,
source: doc.Source,
spells: cloneSpells(doc.Spells),
lookup: lookup,
}, nil
}
func validateSortedStrings(values []string, valid func(string) bool) error {
if !sort.StringsAreSorted(values) {
return fmt.Errorf("values must be sorted")
}
for index, value := range values {
if !valid(value) {
return fmt.Errorf("value %q is not supported", value)
}
if index > 0 && values[index-1] == value {
return fmt.Errorf("value %q is duplicated", value)
}
}
return nil
}
var apostropheVariants = strings.NewReplacer(
"", "'",
"", "'",
"ʼ", "'",
"", "'",
)
func lookupKey(value string) string {
value = apostropheVariants.Replace(value)
return strings.ToLower(strings.Join(strings.Fields(value), " "))
}
func cloneSpells(values []Spell) []Spell {
if values == nil {
return nil
}
out := make([]Spell, len(values))
for index, value := range values {
out[index] = cloneSpell(value)
}
return out
}
func cloneSpell(value Spell) Spell {
value.Classes = append([]string(nil), value.Classes...)
value.Aliases = append([]string(nil), value.Aliases...)
return value
}

View File

@@ -0,0 +1,62 @@
package catalog
import (
"reflect"
"testing"
)
func TestLoadSRD5E2014Catalog(t *testing.T) {
catalog, err := LoadSRD5E2014()
if err != nil {
t.Fatalf("LoadSRD5E2014() error = %v", err)
}
if catalog.ID() != SRD5E2014ID || catalog.Ruleset() != SRD5E2014Ruleset {
t.Fatalf("catalog identity = %q/%q", catalog.ID(), catalog.Ruleset())
}
if source := catalog.Source(); source.Version != "5.1" || source.License != "CC-BY-4.0" || source.URL == "" {
t.Fatalf("catalog source = %#v", source)
}
spells := catalog.Spells()
if len(spells) != 319 {
t.Fatalf("spell count = %d, want 319", len(spells))
}
memberships := 0
for _, spell := range spells {
memberships += len(spell.Classes)
}
if memberships != 779 {
t.Fatalf("class memberships = %d, want 779", memberships)
}
cureWounds, ok := catalog.Lookup("cure wounds")
if !ok || cureWounds.Name != "Cure Wounds" || cureWounds.Level != 1 ||
!reflect.DeepEqual(cureWounds.Classes, []string{"bard", "cleric", "druid", "paladin", "ranger"}) {
t.Fatalf("Cure Wounds lookup = %#v, present=%t", cureWounds, ok)
}
if huntersMark, ok := catalog.Lookup(" HUNTER'S MARK "); !ok || huntersMark.Name != "Hunters Mark" {
t.Fatalf("Hunter's Mark lookup = %#v, present=%t", huntersMark, ok)
}
if _, ok := catalog.Lookup("Definitely Not A Spell"); ok {
t.Fatal("unknown spell lookup succeeded")
}
}
func TestCatalogResultsDoNotExposeMutableStorage(t *testing.T) {
catalog, err := LoadSRD5E2014()
if err != nil {
t.Fatal(err)
}
spells := catalog.Spells()
spells[0].Name = "changed"
spells[0].Classes[0] = "changed"
acidArrow, ok := catalog.Lookup("Acid Arrow")
if !ok || acidArrow.Name != "Acid Arrow" || !reflect.DeepEqual(acidArrow.Classes, []string{"wizard"}) {
t.Fatalf("catalog mutated through Spells(): %#v, present=%t", acidArrow, ok)
}
acidArrow.Classes[0] = "changed"
again, ok := catalog.Lookup("Acid Arrow")
if !ok || !reflect.DeepEqual(again.Classes, []string{"wizard"}) {
t.Fatalf("catalog mutated through Lookup(): %#v, present=%t", again, ok)
}
}