diff --git a/docs/integrations/dnd-spell-catalog-overlays.md b/docs/integrations/dnd-spell-catalog-overlays.md new file mode 100644 index 0000000..9794ce2 --- /dev/null +++ b/docs/integrations/dnd-spell-catalog-overlays.md @@ -0,0 +1,64 @@ +# D&D Spell-Catalog Overlay Contract + +This document defines the JSON format accepted by the D&D spell catalog +resolver. An overlay supplies campaign-specific spell names and aliases for +recognition. It does not supply spell rules, levels, classes, effects, or +source evidence. + +## Shape + +An overlay bundle has this shape: + +```json +{ + "schema_version": "notarius.dnd.spell-catalog-overlay.v1", + "catalogs": [ + { + "id": "campaign.example", + "ruleset": "dnd-5e-2014", + "source": { + "title": "Example campaign spells", + "version": "1", + "url": "", + "license": "" + }, + "spells": [ + { + "name": "Aegis of Emberfall", + "aliases": ["Emberfall Aegis"] + } + ] + } + ] +} +``` + +The top-level `schema_version` and `catalogs` fields are required. The schema +version must be exactly `notarius.dnd.spell-catalog-overlay.v1`, and at least +one catalog is required. Catalogs require a unique, non-empty, trimmed `id`, +the exact `dnd-5e-2014` `ruleset`, a `source`, and a non-empty `spells` array. + +`source.title` is required and must be non-empty and trimmed. `source.version`, +`source.url`, and `source.license` are optional strings and may be empty. +Each spell requires a non-empty, trimmed `name`. `aliases` may be omitted or +may be an array of trimmed, non-empty strings; JSON `null` is not an alias +array. Overlay objects contain no other supported spell fields. + +Decoding is strict: unknown fields, malformed JSON, trailing JSON values, and +non-string optional source fields are rejected. + +## Composition + +The resolver always starts with the embedded D&D 5e 2014 SRD catalog. Overlay +catalogs are sorted by `id` before composition, so the input order does not +affect the result. A new canonical name adds a recognition entry. A canonical +name matching an existing canonical name augments that spell and keeps the +established canonical display spelling. Repeated aliases for the same spell +are idempotent. + +Canonical-name display conflicts and canonical/alias or alias/alias collisions +between different spells are errors, including collisions with the embedded +catalog. Canonical names and aliases use the catalog's case, whitespace, and +common-apostrophe normalization rules. The effective catalog returns canonical +names in sorted order and produces a semantic SHA-256 digest that is stable +under JSON formatting, object-key, catalog, spell, and alias reordering. diff --git a/docs/internal/modules.md b/docs/internal/modules.md index e5d638e..f75d124 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -70,6 +70,17 @@ license details live beside the asset in `SOURCES.md`. This domain-owned data is separate from `internal/modules/dnd/shared`, which is reserved for reusable prompt and source-reference machinery. +`ResolveEffectiveCatalog` builds the immutable recognition view used by later +D&D consumers. It starts with the embedded SRD catalog and optionally applies +one strict JSON overlay from the `spell_catalog` item in a materialized +reference set. Overlay catalogs are ordered by ID, may add names and aliases, +and may augment an existing canonical spell without replacing its display +name. Cross-spell lookup collisions are errors. The effective view exposes +sorted canonical names, normalized lookup, overlay identities, and a semantic +digest; overlay content remains contextual reference material rather than +source evidence. Its external JSON contract is defined in the +[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md). + ## Input Adapter ### `internal/modules/seriatim/input/transcript` diff --git a/internal/modules/dnd/spells/catalog/effective.go b/internal/modules/dnd/spells/catalog/effective.go new file mode 100644 index 0000000..07a0058 --- /dev/null +++ b/internal/modules/dnd/spells/catalog/effective.go @@ -0,0 +1,427 @@ +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 +} diff --git a/internal/modules/dnd/spells/catalog/effective_test.go b/internal/modules/dnd/spells/catalog/effective_test.go new file mode 100644 index 0000000..5397b05 --- /dev/null +++ b/internal/modules/dnd/spells/catalog/effective_test.go @@ -0,0 +1,279 @@ +package catalog + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestResolveEffectiveCatalogBaseAndImmutability(t *testing.T) { + effective, err := ResolveEffectiveCatalog(contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + if effective.BaseID() != SRD5E2014ID || effective.Ruleset() != SRD5E2014Ruleset { + t.Fatalf("identity = %q/%q", effective.BaseID(), effective.Ruleset()) + } + if got := effective.OverlayIDs(); len(got) != 0 { + t.Fatalf("overlay IDs = %#v", got) + } + names := effective.CanonicalNames() + if len(names) != 319 || !sort.StringsAreSorted(names) { + t.Fatalf("canonical names count/order = %d/%t", len(names), sort.StringsAreSorted(names)) + } + if got, ok := effective.Lookup(" HUNTER'S MARK "); !ok || got != "Hunter’s Mark" { + t.Fatalf("Hunter's Mark lookup = %q, present=%t", got, ok) + } + if got, ok := effective.Lookup("Definitely Not A Spell"); ok || got != "" { + t.Fatalf("unknown lookup = %q, present=%t", got, ok) + } + if !strings.HasPrefix(effective.Digest(), "sha256:") { + t.Fatalf("digest = %q", effective.Digest()) + } + + names[0] = "changed" + ids := effective.OverlayIDs() + ids = append(ids, "changed") + again, err := ResolveEffectiveCatalog(contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(names, again.CanonicalNames()) || len(again.OverlayIDs()) != 0 { + t.Fatal("effective catalog exposed mutable result storage") + } +} + +func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) { + overlay := testOverlayJSON(t, testOverlayCatalog( + "campaign.example", + testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), + testOverlaySpell("Cure Wounds", "Healing Touch"), + )) + effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json")) + if err != nil { + t.Fatal(err) + } + if got := effective.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) { + t.Fatalf("overlay IDs = %#v", got) + } + if len(effective.CanonicalNames()) != 320 { + t.Fatalf("canonical name count = %d, want 320", len(effective.CanonicalNames())) + } + if got, ok := effective.Lookup("aegis of emberfall"); !ok || got != "Aegis of Emberfall" { + t.Fatalf("new spell lookup = %q, present=%t", got, ok) + } + if got, ok := effective.Lookup("Emberfall Aegis"); !ok || got != "Aegis of Emberfall" { + t.Fatalf("new alias lookup = %q, present=%t", got, ok) + } + if got, ok := effective.Lookup("Healing Touch"); !ok || got != "Cure Wounds" { + t.Fatalf("augmentation alias lookup = %q, present=%t", got, ok) + } + for _, name := range effective.CanonicalNames() { + if name == "Emberfall Aegis" || name == "Healing Touch" { + t.Fatalf("alias %q was exposed as a canonical name", name) + } + } +} + +func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) { + single := testOverlayJSON(t, testOverlayCatalog( + "campaign.example", + testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), + )) + repeated := testOverlayJSON(t, testOverlayCatalog( + "campaign.example", + testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"), + testOverlaySpell("Aegis of Emberfall", "emberfall aegis"), + )) + first, err := ResolveEffectiveCatalog(overlayReference([]byte(single), "application/json")) + if err != nil { + t.Fatal(err) + } + second, err := ResolveEffectiveCatalog(overlayReference([]byte(repeated), "application/json")) + if err != nil { + t.Fatal(err) + } + if first.Digest() != second.Digest() || !reflect.DeepEqual(first.CanonicalNames(), second.CanonicalNames()) { + t.Fatalf("repeated alias changed effective result: digest=%q/%q", first.Digest(), second.Digest()) + } +} + +func TestResolveEffectiveCatalogDigestIgnoresOrderingAndFormatting(t *testing.T) { + first := `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.z","ruleset":"dnd-5e-2014","source":{"title":"Z spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Z Alias","Another Alias"]}]},{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"A spells","version":"1","url":"","license":""},"spells":[{"name":"Cure Wounds","aliases":["Healing Touch","Cure Mend"]}]}]}` + second := `{ + "catalogs": [ + {"source":{"license":"","url":"","version":"1","title":"A spells"},"spells":[{"aliases":["Cure Mend","Healing Touch"],"name":"Cure Wounds"}],"ruleset":"dnd-5e-2014","id":"campaign.a"}, + {"spells":[{"aliases":["Another Alias","Z Alias"],"name":"Aegis of Emberfall"}],"source":{"title":"Z spells","version":"1","url":"","license":""},"ruleset":"dnd-5e-2014","id":"campaign.z"} + ], + "schema_version":"notarius.dnd.spell-catalog-overlay.v1" +}` + firstCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(first), "application/json")) + if err != nil { + t.Fatal(err) + } + secondCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(second), "application/json")) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()) || firstCatalog.Digest() != secondCatalog.Digest() { + t.Fatalf("reordered overlays changed effective result: names=%t digest=%q/%q", reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()), firstCatalog.Digest(), secondCatalog.Digest()) + } +} + +func TestResolveEffectiveCatalogRejectsInvalidOverlays(t *testing.T) { + valid := validOverlayJSON() + tests := []struct { + name string + content string + }{ + {name: "wrong schema version", content: strings.Replace(valid, "notarius.dnd.spell-catalog-overlay.v1", "notarius.dnd.spell-catalog-overlay.v2", 1)}, + {name: "empty catalogs", content: `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[]}`}, + {name: "duplicate IDs", content: testOverlayJSON(t, testOverlayCatalog("campaign.example", testOverlaySpell("Aegis of Emberfall")), testOverlayCatalog("campaign.example", testOverlaySpell("Another Spell")))}, + {name: "wrong ruleset", content: strings.Replace(valid, `"ruleset":"dnd-5e-2014"`, `"ruleset":"dnd-5e-other"`, 1)}, + {name: "empty ID", content: strings.Replace(valid, `"id":"campaign.example"`, `"id":""`, 1)}, + {name: "empty source title", content: strings.Replace(valid, `"title":"Example campaign spells"`, `"title":""`, 1)}, + {name: "empty spells", content: testOverlayJSON(t, testOverlayCatalog("campaign.example"))}, + {name: "empty spell name", content: strings.Replace(valid, `"name":"Aegis of Emberfall"`, `"name":""`, 1)}, + {name: "null aliases", content: strings.Replace(valid, `"aliases":["Emberfall Aegis"]`, `"aliases":null`, 1)}, + {name: "unknown bundle field", content: strings.Replace(valid, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs"`, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","unexpected":true,"catalogs"`, 1)}, + {name: "unknown catalog field", content: strings.Replace(valid, `"id":"campaign.example","ruleset"`, `"id":"campaign.example","unexpected":true,"ruleset"`, 1)}, + {name: "unknown source field", content: strings.Replace(valid, `"title":"Example campaign spells","version"`, `"title":"Example campaign spells","unexpected":true,"version"`, 1)}, + {name: "unknown spell field", content: strings.Replace(valid, `"name":"Aegis of Emberfall","aliases"`, `"name":"Aegis of Emberfall","unexpected":true,"aliases"`, 1)}, + {name: "trailing JSON", content: valid + `{}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ResolveEffectiveCatalog(overlayReference([]byte(test.content), "application/json")); err == nil { + t.Fatal("invalid overlay was accepted") + } + }) + } +} + +func TestResolveEffectiveCatalogRejectsReferenceMultiplicityAndMediaType(t *testing.T) { + valid := []byte(validOverlayJSON()) + tests := []struct { + name string + refs contracts.ReferenceSet + }{ + { + name: "multiple items", + refs: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + SpellCatalogReferenceSlot: {Items: []contracts.ReferenceItem{ + {MediaType: "application/json", Content: valid}, + {MediaType: "application/json", Content: valid}, + }}, + }}, + }, + { + name: "non JSON item", + refs: overlayReference(valid, "text/plain"), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ResolveEffectiveCatalog(test.refs); err == nil { + t.Fatal("invalid reference set was accepted") + } + }) + } + + for _, refs := range []contracts.ReferenceSet{ + {}, {Slots: map[string]contracts.ResolvedReferenceSlot{SpellCatalogReferenceSlot: {}}}, + } { + if effective, err := ResolveEffectiveCatalog(refs); err != nil || len(effective.CanonicalNames()) != 319 { + t.Fatalf("missing overlay should use base catalog: effective=%#v err=%v", effective, err) + } + } +} + +func TestResolveEffectiveCatalogRejectsCrossSpellCollisions(t *testing.T) { + tests := []struct { + name string + spells []overlaySpell + content string + }{ + { + name: "same key display conflict with embedded canonical", + spells: []overlaySpell{testOverlaySpell("cure wounds")}, + }, + { + name: "same key display conflict between canonical names", + spells: []overlaySpell{testOverlaySpell("Moon Beam"), testOverlaySpell("moon beam")}, + }, + { + name: "canonical versus alias", + spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Beta Spell"), testOverlaySpell("Beta Spell")}, + }, + { + name: "alias versus canonical", + spells: []overlaySpell{testOverlaySpell("Alpha Spell"), testOverlaySpell("Beta Spell", "Alpha Spell")}, + }, + { + name: "alias versus alias", + spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Shared Name"), testOverlaySpell("Beta Spell", "Shared Name")}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + content := test.content + if content == "" { + content = testOverlayJSON(t, testOverlayCatalog("campaign.example", test.spells...)) + } + if _, err := ResolveEffectiveCatalog(overlayReference([]byte(content), "application/json")); err == nil { + t.Fatal("cross-spell collision was accepted") + } + }) + } +} + +func overlayReference(content []byte, mediaType string) contracts.ReferenceSet { + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + SpellCatalogReferenceSlot: { + Items: []contracts.ReferenceItem{{ + SlotName: SpellCatalogReferenceSlot, + MediaType: mediaType, + Content: append([]byte(nil), content...), + }}, + }, + }} +} + +func testOverlaySpell(name string, aliases ...string) overlaySpell { + content, err := json.Marshal(aliases) + if err != nil { + panic(err) + } + return overlaySpell{Name: name, Aliases: content} +} + +func testOverlayCatalog(id string, spells ...overlaySpell) overlayCatalog { + return overlayCatalog{ + ID: id, + Ruleset: SRD5E2014Ruleset, + Source: overlaySource{ + Title: id + " spells", + Version: json.RawMessage(`"1"`), + URL: json.RawMessage(`""`), + License: json.RawMessage(`""`), + }, + Spells: spells, + } +} + +func testOverlayJSON(t *testing.T, catalogs ...overlayCatalog) string { + t.Helper() + content, err := json.Marshal(overlayBundle{SchemaVersion: overlaySchemaVersion, Catalogs: catalogs}) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func validOverlayJSON() string { + return `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Example campaign spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}` +}