428 lines
14 KiB
Go
428 lines
14 KiB
Go
package catalog
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
const (
|
|
SpellCatalogReferenceSlot = "spell_catalog"
|
|
overlaySchemaVersion = "notarius.dnd.spell-catalog-overlay.v1"
|
|
)
|
|
|
|
// EffectiveCatalog is the immutable spell-name recognition catalog assembled
|
|
// from the embedded SRD catalog and an optional set of campaign overlays.
|
|
type EffectiveCatalog struct {
|
|
baseID string
|
|
ruleset string
|
|
overlayIDs []string
|
|
canonicalNames []string
|
|
lookup map[string]string
|
|
digest string
|
|
}
|
|
|
|
func (c EffectiveCatalog) BaseID() string { return c.baseID }
|
|
func (c EffectiveCatalog) Ruleset() string { return c.ruleset }
|
|
func (c EffectiveCatalog) Digest() string { return c.digest }
|
|
func (c EffectiveCatalog) OverlayIDs() []string { return append([]string(nil), c.overlayIDs...) }
|
|
|
|
// CanonicalNames returns the globally sorted canonical spell names without
|
|
// exposing the catalog's internal storage.
|
|
func (c EffectiveCatalog) CanonicalNames() []string {
|
|
return append([]string(nil), c.canonicalNames...)
|
|
}
|
|
|
|
// Lookup matches canonical names and aliases after applying the same
|
|
// normalization used by the embedded catalog. The returned string is the
|
|
// established canonical display name.
|
|
func (c EffectiveCatalog) Lookup(name string) (string, bool) {
|
|
canonical, ok := c.lookup[lookupKey(name)]
|
|
return canonical, ok
|
|
}
|
|
|
|
// ResolveEffectiveCatalog loads the embedded SRD catalog and applies the
|
|
// optional spell-catalog overlay found in the cloned reference set. It does
|
|
// not resolve paths or perform filesystem access.
|
|
func ResolveEffectiveCatalog(references contracts.ReferenceSet) (EffectiveCatalog, error) {
|
|
base, err := LoadSRD5E2014()
|
|
if err != nil {
|
|
return EffectiveCatalog{}, err
|
|
}
|
|
|
|
cloned := cloneReferenceSet(references)
|
|
slot, ok := cloned.Slots[SpellCatalogReferenceSlot]
|
|
if !ok || len(slot.Items) == 0 {
|
|
return composeEffectiveCatalog(base, nil)
|
|
}
|
|
if len(slot.Items) != 1 {
|
|
return EffectiveCatalog{}, fmt.Errorf("reference slot %q must contain zero or one item", SpellCatalogReferenceSlot)
|
|
}
|
|
|
|
item := slot.Items[0]
|
|
mediaType, _, err := mime.ParseMediaType(item.MediaType)
|
|
if err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q is invalid: %w", SpellCatalogReferenceSlot, item.MediaType, err)
|
|
}
|
|
if !strings.EqualFold(mediaType, "application/json") {
|
|
return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q must be application/json", SpellCatalogReferenceSlot, item.MediaType)
|
|
}
|
|
overlays, err := decodeOverlayBundle(item.Content)
|
|
if err != nil {
|
|
return EffectiveCatalog{}, err
|
|
}
|
|
return composeEffectiveCatalog(base, overlays)
|
|
}
|
|
|
|
type overlayBundle struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
Catalogs []overlayCatalog `json:"catalogs"`
|
|
}
|
|
|
|
type overlayCatalog struct {
|
|
ID string `json:"id"`
|
|
Ruleset string `json:"ruleset"`
|
|
Source overlaySource `json:"source"`
|
|
Spells []overlaySpell `json:"spells"`
|
|
}
|
|
|
|
type overlaySource struct {
|
|
Title string `json:"title"`
|
|
Version json.RawMessage `json:"version"`
|
|
URL json.RawMessage `json:"url"`
|
|
License json.RawMessage `json:"license"`
|
|
}
|
|
|
|
type overlaySpell struct {
|
|
Name string `json:"name"`
|
|
Aliases json.RawMessage `json:"aliases,omitempty"`
|
|
}
|
|
|
|
func decodeOverlayBundle(content []byte) ([]overlayCatalog, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
|
decoder.DisallowUnknownFields()
|
|
var bundle overlayBundle
|
|
if err := decoder.Decode(&bundle); err != nil {
|
|
return nil, fmt.Errorf("decode spell catalog overlay: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
return nil, fmt.Errorf("decode spell catalog overlay: multiple JSON values")
|
|
}
|
|
if bundle.SchemaVersion != overlaySchemaVersion {
|
|
return nil, fmt.Errorf("spell catalog overlay schema_version %q does not match %q", bundle.SchemaVersion, overlaySchemaVersion)
|
|
}
|
|
if len(bundle.Catalogs) == 0 {
|
|
return nil, fmt.Errorf("spell catalog overlay catalogs must not be empty")
|
|
}
|
|
|
|
seenIDs := make(map[string]struct{}, len(bundle.Catalogs))
|
|
for catalogIndex := range bundle.Catalogs {
|
|
catalog := &bundle.Catalogs[catalogIndex]
|
|
if catalog.ID != strings.TrimSpace(catalog.ID) || catalog.ID == "" {
|
|
return nil, fmt.Errorf("catalog[%d] id must be non-empty and trimmed", catalogIndex)
|
|
}
|
|
if _, exists := seenIDs[catalog.ID]; exists {
|
|
return nil, fmt.Errorf("catalog id %q is duplicated", catalog.ID)
|
|
}
|
|
seenIDs[catalog.ID] = struct{}{}
|
|
if catalog.Ruleset != SRD5E2014Ruleset {
|
|
return nil, fmt.Errorf("catalog %q ruleset %q does not match %q", catalog.ID, catalog.Ruleset, SRD5E2014Ruleset)
|
|
}
|
|
if catalog.Source.Title != strings.TrimSpace(catalog.Source.Title) || catalog.Source.Title == "" {
|
|
return nil, fmt.Errorf("catalog %q source title must be non-empty and trimmed", catalog.ID)
|
|
}
|
|
for field, raw := range map[string]json.RawMessage{
|
|
"version": catalog.Source.Version,
|
|
"url": catalog.Source.URL,
|
|
"license": catalog.Source.License,
|
|
} {
|
|
if _, err := decodeOptionalString(raw); err != nil {
|
|
return nil, fmt.Errorf("catalog %q source %s: %w", catalog.ID, field, err)
|
|
}
|
|
}
|
|
if len(catalog.Spells) == 0 {
|
|
return nil, fmt.Errorf("catalog %q spells must not be empty", catalog.ID)
|
|
}
|
|
for spellIndex := range catalog.Spells {
|
|
spell := &catalog.Spells[spellIndex]
|
|
if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" {
|
|
return nil, fmt.Errorf("catalog %q spell[%d] name must be non-empty and trimmed", catalog.ID, spellIndex)
|
|
}
|
|
_, err := decodeAliases(spell.Aliases)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("catalog %q spell %q aliases: %w", catalog.ID, spell.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return bundle.Catalogs, nil
|
|
}
|
|
|
|
func decodeOptionalString(raw json.RawMessage) (string, error) {
|
|
if len(raw) == 0 {
|
|
return "", nil
|
|
}
|
|
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return "", fmt.Errorf("must be a string when present")
|
|
}
|
|
var value string
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return "", fmt.Errorf("must be a string when present: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func decodeAliases(raw json.RawMessage) ([]string, error) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return nil, fmt.Errorf("must be an array when present")
|
|
}
|
|
var aliases []string
|
|
if err := json.Unmarshal(raw, &aliases); err != nil {
|
|
return nil, fmt.Errorf("must be an array of strings: %w", err)
|
|
}
|
|
for index, alias := range aliases {
|
|
if alias != strings.TrimSpace(alias) || alias == "" {
|
|
return nil, fmt.Errorf("value at index %d must be non-empty and trimmed", index)
|
|
}
|
|
}
|
|
return aliases, nil
|
|
}
|
|
|
|
type effectiveSpell struct {
|
|
name string
|
|
canonicalKey string
|
|
aliases map[string]string
|
|
}
|
|
|
|
type effectiveBuilder struct {
|
|
spells map[string]*effectiveSpell
|
|
canonicalByKey map[string]string
|
|
lookup map[string]string
|
|
}
|
|
|
|
func composeEffectiveCatalog(base Catalog, overlays []overlayCatalog) (EffectiveCatalog, error) {
|
|
builder := effectiveBuilder{
|
|
spells: make(map[string]*effectiveSpell, len(base.spells)),
|
|
canonicalByKey: make(map[string]string, len(base.lookup)),
|
|
lookup: make(map[string]string, len(base.lookup)),
|
|
}
|
|
for _, spell := range base.Spells() {
|
|
if err := builder.addCanonical(spell.Name); err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
|
|
}
|
|
for _, alias := range spell.Aliases {
|
|
if err := builder.addAlias(spell.Name, alias); err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(overlays, func(i, j int) bool { return overlays[i].ID < overlays[j].ID })
|
|
overlayIDs := make([]string, len(overlays))
|
|
for i, overlay := range overlays {
|
|
overlayIDs[i] = overlay.ID
|
|
for _, spell := range overlay.Spells {
|
|
canonical, err := builder.ensureCanonical(spell.Name)
|
|
if err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("catalog %q: %w", overlay.ID, err)
|
|
}
|
|
aliases, err := decodeAliases(spell.Aliases)
|
|
if err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q aliases: %w", overlay.ID, spell.Name, err)
|
|
}
|
|
for _, alias := range aliases {
|
|
if err := builder.addAlias(canonical, alias); err != nil {
|
|
return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q: %w", overlay.ID, spell.Name, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
canonicalNames := make([]string, 0, len(builder.spells))
|
|
for _, spell := range builder.spells {
|
|
canonicalNames = append(canonicalNames, spell.name)
|
|
}
|
|
sort.Strings(canonicalNames)
|
|
|
|
digest, err := effectiveDigest(base, overlays, builder, canonicalNames)
|
|
if err != nil {
|
|
return EffectiveCatalog{}, err
|
|
}
|
|
return EffectiveCatalog{
|
|
baseID: base.ID(),
|
|
ruleset: base.Ruleset(),
|
|
overlayIDs: overlayIDs,
|
|
canonicalNames: canonicalNames,
|
|
lookup: cloneStringMap(builder.lookup),
|
|
digest: digest,
|
|
}, nil
|
|
}
|
|
|
|
func (b *effectiveBuilder) ensureCanonical(name string) (string, error) {
|
|
key := lookupKey(name)
|
|
if key == "" {
|
|
return "", fmt.Errorf("spell %q has an empty lookup key", name)
|
|
}
|
|
if canonical, exists := b.canonicalByKey[key]; exists {
|
|
if canonical != name {
|
|
return "", fmt.Errorf("canonical lookup key %q has conflicting display names %q and %q", key, canonical, name)
|
|
}
|
|
return canonical, nil
|
|
}
|
|
if canonical, exists := b.lookup[key]; exists {
|
|
return "", fmt.Errorf("canonical lookup key %q collides with alias of spell %q", key, canonical)
|
|
}
|
|
spell := &effectiveSpell{name: name, canonicalKey: key, aliases: make(map[string]string)}
|
|
b.spells[name] = spell
|
|
b.canonicalByKey[key] = name
|
|
b.lookup[key] = name
|
|
return name, nil
|
|
}
|
|
|
|
func (b *effectiveBuilder) addCanonical(name string) error {
|
|
_, err := b.ensureCanonical(name)
|
|
return err
|
|
}
|
|
|
|
func (b *effectiveBuilder) addAlias(canonical string, alias string) error {
|
|
key := lookupKey(alias)
|
|
if key == "" {
|
|
return fmt.Errorf("spell %q has an empty alias lookup key", canonical)
|
|
}
|
|
if existing, exists := b.lookup[key]; exists && existing != canonical {
|
|
return fmt.Errorf("lookup key %q maps to spells %q and %q", key, existing, canonical)
|
|
}
|
|
spell, exists := b.spells[canonical]
|
|
if !exists {
|
|
return fmt.Errorf("spell %q is not present", canonical)
|
|
}
|
|
if key == spell.canonicalKey {
|
|
return nil
|
|
}
|
|
b.lookup[key] = canonical
|
|
if previous, exists := spell.aliases[key]; !exists || alias < previous {
|
|
spell.aliases[key] = alias
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type effectiveDigestDocument struct {
|
|
BaseID string `json:"base_id"`
|
|
BaseRuleset string `json:"base_ruleset"`
|
|
BaseSource Source `json:"base_source"`
|
|
Overlays []effectiveDigestOverlay `json:"overlays"`
|
|
Spells []effectiveDigestSpell `json:"spells"`
|
|
}
|
|
|
|
type effectiveDigestOverlay struct {
|
|
ID string `json:"id"`
|
|
Ruleset string `json:"ruleset"`
|
|
Source struct {
|
|
Title string `json:"title"`
|
|
Version string `json:"version"`
|
|
URL string `json:"url"`
|
|
License string `json:"license"`
|
|
} `json:"source"`
|
|
}
|
|
|
|
type effectiveDigestSpell struct {
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Aliases []effectiveDigestAlias `json:"aliases,omitempty"`
|
|
}
|
|
|
|
type effectiveDigestAlias struct {
|
|
Key string `json:"key"`
|
|
Display string `json:"display"`
|
|
}
|
|
|
|
func effectiveDigest(base Catalog, overlays []overlayCatalog, builder effectiveBuilder, canonicalNames []string) (string, error) {
|
|
document := effectiveDigestDocument{
|
|
BaseID: base.ID(),
|
|
BaseRuleset: base.Ruleset(),
|
|
BaseSource: base.Source(),
|
|
Overlays: make([]effectiveDigestOverlay, len(overlays)),
|
|
Spells: make([]effectiveDigestSpell, 0, len(canonicalNames)),
|
|
}
|
|
for index, overlay := range overlays {
|
|
document.Overlays[index].ID = overlay.ID
|
|
document.Overlays[index].Ruleset = overlay.Ruleset
|
|
version, err := decodeOptionalString(overlay.Source.Version)
|
|
if err != nil {
|
|
return "", fmt.Errorf("catalog %q source version: %w", overlay.ID, err)
|
|
}
|
|
url, err := decodeOptionalString(overlay.Source.URL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("catalog %q source url: %w", overlay.ID, err)
|
|
}
|
|
license, err := decodeOptionalString(overlay.Source.License)
|
|
if err != nil {
|
|
return "", fmt.Errorf("catalog %q source license: %w", overlay.ID, err)
|
|
}
|
|
document.Overlays[index].Source.Title = overlay.Source.Title
|
|
document.Overlays[index].Source.Version = version
|
|
document.Overlays[index].Source.URL = url
|
|
document.Overlays[index].Source.License = license
|
|
}
|
|
for _, name := range canonicalNames {
|
|
spell := builder.spells[name]
|
|
digestSpell := effectiveDigestSpell{Key: spell.canonicalKey, Name: spell.name}
|
|
keys := make([]string, 0, len(spell.aliases))
|
|
for key := range spell.aliases {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
digestSpell.Aliases = append(digestSpell.Aliases, effectiveDigestAlias{Key: key, Display: spell.aliases[key]})
|
|
}
|
|
document.Spells = append(document.Spells, digestSpell)
|
|
}
|
|
raw, err := json.Marshal(document)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode effective spell catalog digest: %w", err)
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func cloneStringMap(values map[string]string) map[string]string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneReferenceSet(in contracts.ReferenceSet) contracts.ReferenceSet {
|
|
if len(in.Slots) == 0 {
|
|
return contracts.ReferenceSet{}
|
|
}
|
|
out := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(in.Slots))}
|
|
for name, slot := range in.Slots {
|
|
slot.Slot.AcceptedMediaTypes = append([]string(nil), slot.Slot.AcceptedMediaTypes...)
|
|
items := make([]contracts.ReferenceItem, len(slot.Items))
|
|
for index, item := range slot.Items {
|
|
item.Content = append([]byte(nil), item.Content...)
|
|
items[index] = item
|
|
}
|
|
slot.Items = items
|
|
out.Slots[name] = slot
|
|
}
|
|
return out
|
|
}
|