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