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 its spell_catalog reference slot. 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 } slot, ok := references.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 }