215 lines
5.9 KiB
Go
215 lines
5.9 KiB
Go
// 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
|
||
}
|