Files

215 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}