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,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
}