Files
gogobee/cmd/open5e-import/gen.go
prosolis 6ef8b9fd0a 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.
2026-05-14 15:18:18 -07:00

330 lines
9.8 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
)
// classEnum maps an Open5e spell_lists entry to the plugin's DnDClass enum
// identifier. Open5e's "wizard" is the plugin's Mage; classes the plugin
// doesn't model (artificer, etc.) are absent and silently dropped.
//
// classOrder fixes the iteration order so generated Classes slices are stable.
var (
classEnum = map[string]string{
"wizard": "ClassMage",
"cleric": "ClassCleric",
"ranger": "ClassRanger",
"druid": "ClassDruid",
"bard": "ClassBard",
"sorcerer": "ClassSorcerer",
"warlock": "ClassWarlock",
"paladin": "ClassPaladin",
}
classOrder = []string{"wizard", "cleric", "ranger", "druid", "bard", "sorcerer", "warlock", "paladin"}
)
// mapClasses turns an Open5e spell's class tags into plugin enum identifiers,
// deduped and in classOrder. It unions the structured spell_lists array with
// the free-text dnd_class field ("Druid, Wizard") — the SRD dump's spell_lists
// omits paladin entirely, so dnd_class is the more complete source. Returns
// nil when the spell belongs to no modelled class (the caller drops those —
// they have no caster to learn them).
func mapClasses(lists []string, dndClass string) []string {
seen := map[string]bool{}
for _, l := range lists {
seen[strings.ToLower(strings.TrimSpace(l))] = true
}
for _, l := range strings.Split(dndClass, ",") {
seen[strings.ToLower(strings.TrimSpace(l))] = true
}
var out []string
for _, c := range classOrder {
if seen[c] {
out = append(out, classEnum[c])
}
}
return out
}
// genSpell is a fully classified spell ready for code emission. Fields mirror
// plugin.SpellDefinition; enum-typed fields hold the Go identifier as a string.
type genSpell struct {
ID string
Name string
Level int
School string
Classes []string
Effect string
CastTime string
Concentration bool
SaveStat string
AttackRoll bool
DamageDice string
DamageType string
Description string
Upcast string
MaterialCost int
AOE bool
}
// genSpells reads the vendored spells.json, classifies every in-scope spell,
// and writes the generated Go data file.
func genSpells() error {
raw, err := os.ReadFile(spellsJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch spells` first?): %w", spellsJSON, err)
}
var src []open5eSpell
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
var spells []genSpell
var skippedLevel, skippedNoClass int
for _, s := range src {
if s.LevelInt > 5 {
// Combat caps at L5 spell slots — nothing can ever cast these.
skippedLevel++
continue
}
classes := mapClasses(s.SpellLists, s.DndClass)
if len(classes) == 0 {
skippedNoClass++
continue
}
spells = append(spells, classify(s, classes))
}
sort.Slice(spells, func(i, j int) bool {
if spells[i].Level != spells[j].Level {
return spells[i].Level < spells[j].Level
}
return spells[i].ID < spells[j].ID
})
fmt.Fprintf(os.Stderr, "classified %d spells (skipped %d level>5, %d no modelled class)\n",
len(spells), skippedLevel, skippedNoClass)
out := emit(spells)
if err := os.WriteFile(spellsGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", spellsGenGo)
return nil
}
// ── Classifier ───────────────────────────────────────────────────────────────
var (
reSave = regexp.MustCompile(`(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw`)
reDice = regexp.MustCompile(`\d+d\d+(?:\s*\+\s*\d+)?`)
reDmgType = regexp.MustCompile(`(?i)(acid|bludgeoning|cold|fire|force|lightning|necrotic|piercing|poison|psychic|radiant|slashing|thunder)\s+damage`)
reMaterial = regexp.MustCompile(`(?i)worth (?:at least )?([\d,]+)\s*gp`)
saveStatMap = map[string]string{
"Strength": "STR", "Dexterity": "DEX", "Constitution": "CON",
"Intelligence": "INT", "Wisdom": "WIS", "Charisma": "CHA",
}
)
func classify(s open5eSpell, classes []string) genSpell {
desc := strings.TrimSpace(s.Desc)
low := strings.ToLower(desc)
g := genSpell{
ID: strings.ReplaceAll(s.Slug, "-", "_"),
Name: s.Name,
Level: s.LevelInt,
School: strings.ToLower(strings.TrimSpace(s.School)),
Classes: classes,
Concentration: s.RequiresConcentration,
Description: firstSentence(desc, 200),
Upcast: firstSentence(strings.TrimSpace(s.HigherLevel), 200),
AOE: looksAOE(low),
}
// Cast time. Reaction/bonus action win over the ritual flag.
ct := strings.ToLower(s.CastingTime)
switch {
case strings.Contains(ct, "bonus action"):
g.CastTime = "CastBonusAction"
case strings.Contains(ct, "reaction"):
g.CastTime = "CastReaction"
case s.CanBeCastAsRitual:
g.CastTime = "CastRitual"
default:
g.CastTime = "CastAction"
}
if m := reSave.FindStringSubmatch(desc); m != nil {
g.SaveStat = saveStatMap[m[1]]
}
g.AttackRoll = strings.Contains(low, "spell attack")
if m := reDice.FindString(desc); m != "" {
g.DamageDice = strings.ReplaceAll(m, " ", "")
}
if m := reDmgType.FindStringSubmatch(low); m != nil {
g.DamageType = strings.ToLower(m[1])
}
if m := reMaterial.FindStringSubmatch(s.Material); m != nil {
g.MaterialCost, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
}
g.Effect = classifyEffect(g, low)
// A heal spell's dice describe healing, not damage — keep DamageDice (the
// hand-authored list does the same) but drop a stray damage type.
if g.Effect == "EffectSpellHeal" {
g.DamageType = ""
}
// Non-damaging effects shouldn't carry a damage type/dice picked up from
// incidental wording.
if g.Effect == "EffectControl" || g.Effect == "EffectUtility" ||
g.Effect == "EffectReaction" {
g.DamageDice, g.DamageType = "", ""
}
return g
}
// classifyEffect picks the SpellEffectKind. Heuristic and imperfect by design —
// the hand-authored buildSpellList() overlay is the refinement path, so this
// only needs to be a reasonable default.
func classifyEffect(g genSpell, low string) string {
if g.CastTime == "CastReaction" {
return "EffectReaction"
}
isHeal := strings.Contains(low, "regain") && strings.Contains(low, "hit point") &&
!strings.Contains(low, "damage")
if isHeal {
return "EffectSpellHeal"
}
if g.DamageDice != "" && g.DamageType != "" {
switch {
case g.AttackRoll:
return "EffectDamageAttack"
case g.SaveStat != "":
return "EffectDamageSave"
default:
return "EffectDamageAuto"
}
}
if g.SaveStat != "" {
return "EffectControl"
}
// Light buff detection — anything still unclassified falls through to
// utility, which is the safe bucket.
if strings.Contains(low, "bonus to") || strings.Contains(low, "you gain") ||
strings.Contains(low, "gains a bonus") || strings.Contains(low, "your ac") {
if strings.Contains(low, "you gain") || strings.Contains(low, "yourself") ||
strings.Contains(low, "your ac") {
return "EffectBuffSelf"
}
return "EffectBuffAlly"
}
return "EffectUtility"
}
func looksAOE(low string) bool {
for _, kw := range []string{
"each creature", "all creatures", "-foot radius", "-foot-radius",
"foot cone", "foot cube", "foot line", "foot-radius sphere",
} {
if strings.Contains(low, kw) {
return true
}
}
return false
}
// firstSentence trims free-text fields down to a single sentence, capped at
// max runes, so generated Description/Upcast stay terminal-friendly.
func firstSentence(s string, max int) string {
if s == "" {
return ""
}
if i := strings.IndexAny(s, "\n"); i >= 0 {
s = s[:i]
}
if i := strings.Index(s, ". "); i >= 0 {
s = s[:i+1]
}
s = strings.TrimSpace(s)
if len(s) > max {
s = strings.TrimSpace(s[:max]) + "…"
}
return s
}
// ── Code emission ────────────────────────────────────────────────────────────
func emit(spells []genSpell) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD spell dump (data/open5e/spells.json), 5e SRD content
// under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch spells
// go run ./cmd/open5e-import gen spells
//
// Effect/CastTime/SaveStat and friends come from a free-text classifier; the
// hand-authored buildSpellList() overlays this slice and wins on ID collision,
// so any misclassification here is correctable there rather than in the dump.
package plugin
func buildSRDSpellList() []SpellDefinition {
return []SpellDefinition{
`)
for _, s := range spells {
b.WriteString("\t\t{")
fmt.Fprintf(&b, "ID: %q, Name: %q, Level: %d", s.ID, s.Name, s.Level)
if s.School != "" {
fmt.Fprintf(&b, ", School: %q", s.School)
}
b.WriteString(", Classes: []DnDClass{")
for i, c := range s.Classes {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(c)
}
b.WriteString("}")
fmt.Fprintf(&b, ", Effect: %s, CastTime: %s", s.Effect, s.CastTime)
if s.Concentration {
b.WriteString(", Concentration: true")
}
if s.SaveStat != "" {
fmt.Fprintf(&b, ", SaveStat: %q", s.SaveStat)
}
if s.AttackRoll {
b.WriteString(", AttackRoll: true")
}
if s.DamageDice != "" {
fmt.Fprintf(&b, ", DamageDice: %q", s.DamageDice)
}
if s.DamageType != "" {
fmt.Fprintf(&b, ", DamageType: %q", s.DamageType)
}
if s.Description != "" {
fmt.Fprintf(&b, ", Description: %q", s.Description)
}
if s.Upcast != "" {
fmt.Fprintf(&b, ", Upcast: %q", s.Upcast)
}
if s.MaterialCost != 0 {
fmt.Fprintf(&b, ", MaterialCost: %d", s.MaterialCost)
}
if s.AOE {
b.WriteString(", AOE: true")
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}