D&D: import Open5e SRD spell lists, make the five casters playable

Rebuilds the cmd/open5e-import CLI (fetch/gen × spells) to vendor
data/open5e/spells.json (319 SRD spells) and generate
dnd_spells_srd_data.go (237 after the level>5 filter). mapClasses unions
the API's incomplete structured spell_lists field with the complete
free-text dnd_class field so all eight casters get spells.

dndSpellRegistry loads buildSRDSpellList() first; the hand-authored
buildSpellList() overlays it (hand wins on ID collision). Playable=true
flipped for Druid/Bard/Sorcerer/Warlock/Paladin, each with a
defaultKnownSpells case. TestDefaultKnownSpellsExistInRegistry now covers
all eight classes.

.gitignore: vendor data/open5e/ while keeping the rest of data/ ignored,
and anchor the open5e-import binary pattern so it stops swallowing the
cmd/open5e-import source dir. NOTICE adds CC-BY-4.0 / SRD attribution.
This commit is contained in:
prosolis
2026-05-14 15:18:18 -07:00
parent e6e0009253
commit 6ef8b9fd0a
9 changed files with 7110 additions and 10 deletions

141
cmd/open5e-import/main.go Normal file
View File

@@ -0,0 +1,141 @@
// Command open5e-import vendors Open5e SRD data (CC-BY-4.0) into the repo and
// regenerates the Go data files the plugin compiles against.
//
// It is a pull-once / use-many tool — the generated files are committed, so
// the running bot has no runtime dependency on the Open5e API.
//
// open5e-import fetch spells # vendor data/open5e/spells.json from the API
// open5e-import gen spells # classify → internal/plugin/dnd_spells_srd_data.go
//
// `bestiary` and `equipment` subcommands are planned; the dispatch below is
// structured to grow into them without reshuffling.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
const (
spellsAPIBase = "https://api.open5e.com/v1/spells/?document__slug=wotc-srd&limit=50"
spellsJSON = "data/open5e/spells.json"
spellsGenGo = "internal/plugin/dnd_spells_srd_data.go"
)
func main() {
if len(os.Args) < 3 {
usage()
}
verb, noun := os.Args[1], os.Args[2]
switch verb {
case "fetch":
switch noun {
case "spells":
must(fetchSpells())
default:
usage()
}
case "gen":
switch noun {
case "spells":
must(genSpells())
default:
usage()
}
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: open5e-import (fetch|gen) (spells)")
os.Exit(2)
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
// fields are ignored; the vendored JSON keeps the full payload.
type open5eSpell struct {
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
HigherLevel string `json:"higher_level"`
Range string `json:"range"`
Material string `json:"material"`
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
Duration string `json:"duration"`
RequiresConcentration bool `json:"requires_concentration"`
CastingTime string `json:"casting_time"`
LevelInt int `json:"level_int"`
School string `json:"school"`
// SpellLists is the structured class list, but the SRD dump leaves it
// incomplete (no paladin entries at all). DndClass is the free-text
// "Druid, Wizard" field and is the more complete source — mapClasses
// unions both. Keep both fields vendored.
SpellLists []string `json:"spell_lists"`
DndClass string `json:"dnd_class"`
}
type spellsPage struct {
Next string `json:"next"`
Results []open5eSpell `json:"results"`
}
// fetchSpells pages through the SRD spell list and writes the full result set
// to data/open5e/spells.json, pretty-printed for a reviewable diff.
func fetchSpells() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eSpell
url := spellsAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getSpellsPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d spells\n", len(all))
if err := os.MkdirAll(filepath.Dir(spellsJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(spellsJSON, out, 0o644)
}
func getSpellsPage(client *http.Client, url string) (*spellsPage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var page spellsPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}