From f5107045c3acb8eee0139c0fa2dc66d0446aba8c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 20 Jul 2026 20:52:27 +0000 Subject: [PATCH] Add deterministic D&D spell normalizer foundation --- .../dnd/normalize/spells/normalizer.go | 238 ++++++++++++++ .../dnd/normalize/spells/normalizer_test.go | 298 ++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 internal/modules/dnd/normalize/spells/normalizer.go create mode 100644 internal/modules/dnd/normalize/spells/normalizer_test.go diff --git a/internal/modules/dnd/normalize/spells/normalizer.go b/internal/modules/dnd/normalize/spells/normalizer.go new file mode 100644 index 0000000..28c76a4 --- /dev/null +++ b/internal/modules/dnd/normalize/spells/normalizer.go @@ -0,0 +1,238 @@ +package spells + +import ( + "context" + "fmt" + "sort" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" +) + +const Key = "dnd/spells" + +const ( + ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized" + ReasonCodeSpellNameUnresolved = "spell_name_unresolved" + ReasonCodeSourceReferencesNormalized = "source_references_normalized" +) + +var requiredCapabilities = []string{"merged"} +var providedCapabilities = []string{"normalized"} + +var _ contracts.Normalizer[dnd.SpellList] = (*Normalizer)(nil) +var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) + +type Options struct{} + +type Normalizer struct { + effectiveCatalog spellcatalog.EffectiveCatalog +} + +func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) { + if len(references) > 1 { + return nil, normalizerErrorf("at most one reference set may be supplied") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + effectiveCatalog, err := spellcatalog.ResolveEffectiveCatalog(referenceSet) + if err != nil { + return nil, normalizerErrorf("resolve effective spell catalog: %w", err) + } + return &Normalizer{effectiveCatalog: effectiveCatalog}, nil +} + +func (n *Normalizer) Key() string { + return Key +} + +func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { + return referenceSlots() +} + +func (n *Normalizer) ManifestMetadata() map[string]any { + if n == nil { + return nil + } + return map[string]any{ + "catalog_base_id": n.effectiveCatalog.BaseID(), + "catalog_digest": n.effectiveCatalog.Digest(), + "catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...), + } +} + +func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if n == nil { + return nil + } + return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}} +} + +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) { + if n == nil { + return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("normalizer must not be nil") + } + if ctx == nil { + return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context must not be nil") + } + if err := ctx.Err(); err != nil { + return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err) + } + + value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog) + return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil +} + +func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) { + var warnings []contracts.Warning + if input.SpellCasts == nil { + return dnd.SpellList{}, nil + } + + output := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, len(input.SpellCasts))} + for index, inputCast := range input.SpellCasts { + cast := cloneSpellCast(inputCast) + if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok { + if inputCast.Spell != canonicalName { + warnings = append(warnings, contracts.Warning{ + Scope: spellCastScope(index), + ReasonCode: ReasonCodeSpellNameCanonicalized, + Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q", + index, boundedName(inputCast.Spell), boundedName(canonicalName)), + }) + } + cast.Spell = canonicalName + } else { + warnings = append(warnings, contracts.Warning{ + Scope: spellCastScope(index), + ReasonCode: ReasonCodeSpellNameUnresolved, + Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog", + index, boundedName(inputCast.Spell)), + }) + } + + canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(inputCast.SourceRefs) + cast.SourceRefs = canonicalRefs + if orderChanged || duplicateCount > 0 { + warnings = append(warnings, contracts.Warning{ + Scope: spellCastScope(index), + ReasonCode: ReasonCodeSourceReferencesNormalized, + Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)", + index, len(inputCast.SourceRefs), len(canonicalRefs), orderChanged, duplicateCount), + }) + } + output.SpellCasts[index] = cast + } + return output, warnings +} + +func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast { + output := input + if input.SourceRefs != nil { + output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) + copy(output.SourceRefs, input.SourceRefs) + } + return output +} + +func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) { + if input == nil { + return nil, false, 0 + } + + canonical := make([]source.SourceRef, len(input)) + copy(canonical, input) + sort.SliceStable(canonical, func(left, right int) bool { + return sourceRefLess(canonical[left], canonical[right]) + }) + + orderChanged := false + for index := range input { + if input[index] != canonical[index] { + orderChanged = true + break + } + } + + unique := make([]source.SourceRef, 0, len(canonical)) + for _, ref := range canonical { + if len(unique) == 0 || unique[len(unique)-1] != ref { + unique = append(unique, ref) + } + } + return unique, orderChanged, len(input) - len(unique) +} + +func sourceRefLess(left, right source.SourceRef) bool { + if left.SourceID != right.SourceID { + return left.SourceID < right.SourceID + } + if left.StartUnitID != right.StartUnitID { + return left.StartUnitID < right.StartUnitID + } + return left.EndUnitID < right.EndUnitID +} + +func boundedName(name string) string { + runes := []rune(name) + if len(runes) <= 128 { + return string(runes) + } + return string(runes[:127]) + "…" +} + +func spellCastScope(index int) string { + return fmt.Sprintf("spell_casts[%d]", index) +} + +func ModuleSpec() pipeline.ModuleSpec { + return pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageNormalize, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.SpellListKind, + ReferenceSlots: referenceSlots(), + } +} + +func Register(registry *pipeline.NormalizerRegistry) error { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SpellList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options, request.References) + }) +} + +func validateOptions(options map[string]any) error { + _, err := DecodeOptions(options) + return err +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, normalizerErrorf("%w", err) + } + return Options{}, nil +} + +func referenceSlots() []contracts.ReferenceSlot { + return []contracts.ReferenceSlot{{ + Name: spellcatalog.SpellCatalogReferenceSlot, + Description: "Optional canonical spell-name catalog used for extraction grounding.", + AcceptedMediaTypes: []string{"application/json"}, + MaxBytes: 1048576, + }} +} + +func normalizerErrorf(format string, args ...any) error { + return fmt.Errorf("dnd spells normalizer: "+format, args...) +} diff --git a/internal/modules/dnd/normalize/spells/normalizer_test.go b/internal/modules/dnd/normalize/spells/normalizer_test.go new file mode 100644 index 0000000..3407852 --- /dev/null +++ b/internal/modules/dnd/normalize/spells/normalizer_test.go @@ -0,0 +1,298 @@ +package spells + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" +) + +func TestModuleContractAndStrictOptions(t *testing.T) { + if _, err := DecodeOptions(nil); err != nil { + t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) + } + + want := pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageNormalize, + Requires: []string{"merged"}, + Provides: []string{"normalized"}, + ArtifactKind: dnd.SpellListKind, + ReferenceSlots: []contracts.ReferenceSlot{{ + Name: spellcatalog.SpellCatalogReferenceSlot, + Description: "Optional canonical spell-name catalog used for extraction grounding.", + AcceptedMediaTypes: []string{"application/json"}, + MaxBytes: 1048576, + }}, + } + if got := ModuleSpec(); !reflect.DeepEqual(got, want) { + t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) + } + + registry := pipeline.NewNormalizerRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + registered, ok := registry.SpecForArtifact(Key, dnd.SpellListKind) + if !ok || !reflect.DeepEqual(registered, want) { + t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want) + } + if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") { + t.Fatalf("Register(nil) error = %v, want registry error", err) + } +} + +func TestNewBuildsEmbeddedAndOverlayCatalogs(t *testing.T) { + base, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + if base.effectiveCatalog.BaseID() != spellcatalog.SRD5E2014ID || base.effectiveCatalog.Digest() == "" || len(base.effectiveCatalog.OverlayIDs()) != 0 { + t.Fatalf("base catalog identity = %#v, want embedded catalog identity", base.effectiveCatalog) + } + if canonical, ok := base.effectiveCatalog.Lookup(" cure wounds "); !ok || canonical != "Cure Wounds" { + t.Fatalf("base catalog lookup = %q, %t, want Cure Wounds", canonical, ok) + } + if canonical, ok := base.effectiveCatalog.Lookup("Arcanist's Magic Aura"); !ok || canonical != "Arcanist’s Magic Aura" { + t.Fatalf("apostrophe lookup = %q, %t, want canonical curly apostrophe spelling", canonical, ok) + } + + overlay, err := New(Options{}, overlayReference()) + if err != nil { + t.Fatalf("New(overlay) error = %v, want nil", err) + } + if got := overlay.effectiveCatalog.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) { + t.Fatalf("overlay IDs = %#v, want campaign.example", got) + } + if canonical, ok := overlay.effectiveCatalog.Lookup(" emberfall aegis "); !ok || canonical != "Aegis of Emberfall" { + t.Fatalf("overlay alias lookup = %q, %t, want Aegis of Emberfall", canonical, ok) + } +} + +func TestNewRejectsInvalidCatalogReferencesDuringConstruction(t *testing.T) { + invalid := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + spellcatalog.SpellCatalogReferenceSlot: { + Items: []contracts.ReferenceItem{{MediaType: "text/plain", Content: []byte("not a catalog")}}, + }, + }} + if _, err := New(Options{}, invalid); err == nil || !strings.Contains(err.Error(), "application/json") { + t.Fatalf("New(invalid reference) error = %v, want media type failure", err) + } + + tooMany := overlayReference() + slot := tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot] + slot.Items = append(slot.Items, slot.Items[0]) + tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot] = slot + if _, err := New(Options{}, tooMany); err == nil || !strings.Contains(err.Error(), "zero or one item") { + t.Fatalf("New(duplicated reference) error = %v, want multiplicity failure", err) + } +} + +func TestIdentityAndMetadataAreDefensive(t *testing.T) { + normalizer, err := New(Options{}, overlayReference()) + if err != nil { + t.Fatal(err) + } + + fingerprints := normalizer.CheckpointFingerprints() + if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() { + t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints) + } + fingerprints[0].Name = "changed" + fingerprints[0].Value = "changed" + if got := normalizer.CheckpointFingerprints(); len(got) != 1 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() { + t.Fatalf("fingerprints were not defensive: %#v", got) + } + + metadata := normalizer.ManifestMetadata() + if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() { + t.Fatalf("metadata = %#v, want catalog identity", metadata) + } + metadata["catalog_overlay_ids"].([]string)[0] = "changed" + if got := normalizer.ManifestMetadata()["catalog_overlay_ids"].([]string); !reflect.DeepEqual(got, []string{"campaign.example"}) { + t.Fatalf("metadata overlay IDs were not defensive: %#v", got) + } +} + +func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) { + normalizer := newNormalizer(t) + input := dnd.SpellList{SpellCasts: []dnd.SpellCast{ + {Spell: " cure wounds "}, + {Spell: "Arcanist's Magic Aura"}, + {Spell: "Emberfall Aegis"}, + {Spell: "Mystery\nSpell\tName"}, + {Spell: "Cure Wounds"}, + }} + result, err := normalizer.Normalize(context.Background(), normalizeRequest(input)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + wantNames := []string{"Cure Wounds", "Arcanist’s Magic Aura", "Aegis of Emberfall", "Mystery\nSpell\tName", "Cure Wounds"} + for index, want := range wantNames { + if result.Value.SpellCasts[index].Spell != want { + t.Fatalf("spell[%d] = %q, want %q", index, result.Value.SpellCasts[index].Spell, want) + } + } + if len(result.Warnings) != 4 { + t.Fatalf("warnings = %#v, want four name warnings", result.Warnings) + } + if result.Warnings[0].ReasonCode != ReasonCodeSpellNameCanonicalized || result.Warnings[0].Scope != "spell_casts[0]" || !strings.Contains(result.Warnings[0].Message, "input index 0") { + t.Fatalf("first warning = %#v, want canonicalization warning", result.Warnings[0]) + } + if result.Warnings[1].ReasonCode != ReasonCodeSpellNameCanonicalized || result.Warnings[2].ReasonCode != ReasonCodeSpellNameCanonicalized { + t.Fatalf("catalog spelling warnings = %#v", result.Warnings[1:3]) + } + if result.Warnings[3].ReasonCode != ReasonCodeSpellNameUnresolved || result.Warnings[3].Scope != "spell_casts[3]" || strings.Contains(result.Warnings[3].Message, "Mystery\nSpell") || !strings.Contains(result.Warnings[3].Message, `Mystery\nSpell\tName`) { + t.Fatalf("unresolved warning = %#v, want quoted control characters", result.Warnings[3]) + } +} + +func TestNormalizeBoundsUnicodeNamesAndQuotesCanonicalReplacement(t *testing.T) { + longName := strings.Repeat("火", 140) + reference := spellCatalogReference(fmt.Sprintf(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.long","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source"},"spells":[{"name":%q,"aliases":["long alias"]}]}]}`, longName)) + normalizer, err := New(Options{}, reference) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "long alias"}}})) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSpellNameCanonicalized { + t.Fatalf("warnings = %#v, want one canonicalization warning", result.Warnings) + } + if !utf8.ValidString(result.Warnings[0].Message) || !strings.Contains(result.Warnings[0].Message, "…") || strings.Contains(result.Warnings[0].Message, longName) { + t.Fatalf("warning = %q, want valid bounded Unicode diagnostic", result.Warnings[0].Message) + } + if got := result.Value.SpellCasts[0].Spell; got != longName { + t.Fatalf("canonical value = %q, want full catalog name", got) + } +} + +func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) { + normalizer := newNormalizer(t) + inputRefs := []source.SourceRef{ + {SourceID: "source-b", StartUnitID: 4, EndUnitID: 5}, + {SourceID: "source-a", StartUnitID: 3, EndUnitID: 4}, + {SourceID: "source-a", StartUnitID: 3, EndUnitID: 4}, + {SourceID: "source-a", StartUnitID: 1, EndUnitID: 2}, + {SourceID: "source-a", StartUnitID: 2, EndUnitID: 3}, + {SourceID: "source-a", StartUnitID: 1, EndUnitID: 4}, + } + input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds", SourceRefs: inputRefs}}} + result, err := normalizer.Normalize(context.Background(), normalizeRequest(input)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + wantRefs := []source.SourceRef{ + {SourceID: "source-a", StartUnitID: 1, EndUnitID: 2}, + {SourceID: "source-a", StartUnitID: 1, EndUnitID: 4}, + {SourceID: "source-a", StartUnitID: 2, EndUnitID: 3}, + {SourceID: "source-a", StartUnitID: 3, EndUnitID: 4}, + {SourceID: "source-b", StartUnitID: 4, EndUnitID: 5}, + } + if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) { + t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs) + } + if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") { + t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings) + } +} + +func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) { + normalizer := newNormalizer(t) + input := dnd.SpellList{SpellCasts: []dnd.SpellCast{ + {Spell: "Cure Wounds", SourceRefs: nil}, + {Spell: "Cure Wounds", SourceRefs: []source.SourceRef{}}, + {Spell: "Cure Wounds", SourceRefs: []source.SourceRef{ + {SourceID: "source", StartUnitID: 3, EndUnitID: 4}, + {SourceID: "source", StartUnitID: 1, EndUnitID: 2}, + {SourceID: "source", StartUnitID: 2, EndUnitID: 5}, + }}, + }} + before := input + before.SpellCasts = append([]dnd.SpellCast(nil), input.SpellCasts...) + before.SpellCasts[2].SourceRefs = append([]source.SourceRef(nil), input.SpellCasts[2].SourceRefs...) + result, err := normalizer.Normalize(context.Background(), normalizeRequest(input)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + if result.Value.SpellCasts[0].SourceRefs != nil || result.Value.SpellCasts[1].SourceRefs == nil { + t.Fatalf("nil/empty source refs were not preserved: %#v", result.Value.SpellCasts) + } + if len(result.Value.SpellCasts[2].SourceRefs) != 3 { + t.Fatalf("adjacent/overlapping references = %#v, want all three retained", result.Value.SpellCasts[2].SourceRefs) + } + if !reflect.DeepEqual(input, before) { + t.Fatalf("Normalize() mutated input: got %#v, before %#v", input, before) + } + result.Value.SpellCasts[2].SourceRefs[0].SourceID = "changed" + if input.SpellCasts[2].SourceRefs[0].SourceID == "changed" { + t.Fatal("normalized references share input storage") + } +} + +func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { + request := normalizeRequest(dnd.SpellList{}) + var normalizer *Normalizer + if _, err := normalizer.Normalize(context.Background(), request); err == nil || !strings.Contains(err.Error(), "normalizer must not be nil") { + t.Fatalf("nil receiver error = %v, want nil receiver error", err) + } + normalizer = newNormalizer(t) + if _, err := normalizer.Normalize(nil, request); err == nil || !strings.Contains(err.Error(), "context must not be nil") { + t.Fatalf("nil context error = %v, want nil context error", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := normalizer.Normalize(ctx, request); err == nil || !strings.Contains(err.Error(), "context error before normalize") { + t.Fatalf("canceled context error = %v, want canceled context error", err) + } +} + +func newNormalizer(t *testing.T) *Normalizer { + t.Helper() + normalizer, err := New(Options{}, overlayReference()) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + return normalizer +} + +func normalizeRequest(value dnd.SpellList) contracts.TypedNormalizeRequest[dnd.SpellList] { + return contracts.TypedNormalizeRequest[dnd.SpellList]{ + LaneID: "spells", + MergeOutput: contracts.MergeArtifact[dnd.SpellList]{ + LaneID: "spells", + MergerKey: "appendorder", + SourceID: "source", + Value: value, + }, + } +} + +func overlayReference() contracts.ReferenceSet { + return spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`) +} + +func spellCatalogReference(content string) contracts.ReferenceSet { + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + spellcatalog.SpellCatalogReferenceSlot: { + Items: []contracts.ReferenceItem{{ + SlotName: spellcatalog.SpellCatalogReferenceSlot, + MediaType: "application/json", + Content: []byte(content), + }}, + }, + }} +}