Add feature roadmap and implementation plan for D&D spell extraction and validation upgrades
This commit is contained in:
214
internal/modules/dnd/spells/catalog/catalog.go
Normal file
214
internal/modules/dnd/spells/catalog/catalog.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user