diff --git a/internal/cli/dnd_interactions_contract_test.go b/internal/cli/dnd_interactions_contract_test.go new file mode 100644 index 0000000..2d02355 --- /dev/null +++ b/internal/cli/dnd_interactions_contract_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" + interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" + npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" + interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" +) + +func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) { + components := productionTestComponents(t) + resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), pipeline.ResolveOptions{}, catalogFromRegistries(components.registries)) + if err != nil { + t.Fatalf("ResolvePipeline() error = %v", err) + } + if len(resolved.Steps) != 2 || len(resolved.Steps[1].ArtifactLanes) != 1 { + t.Fatalf("resolved pipeline = %#v", resolved) + } + lane := resolved.Steps[1].ArtifactLanes[0] + if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key { + t.Fatalf("interaction lane = %#v", lane) + } + for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} { + if len(bindings) != 1 || bindings[0].SlotName != "npcs" || bindings[0].Artifact == nil || bindings[0].Artifact.Step != "npcs" || bindings[0].Artifact.Lane != "npcs" { + t.Fatalf("generated bindings = %#v", bindings) + } + } + if _, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil { + t.Fatalf("Prepare() error = %v", err) + } + + catalog := catalogFromRegistries(components.registries) + codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind) + if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion { + t.Fatalf("NPC interaction codec spec = %#v", codecSpec) + } +} + +func TestProductionNPCInteractionReferencesRequireEarlierCompatibleProducer(t *testing.T) { + components := productionTestComponents(t) + catalog := catalogFromRegistries(components.registries) + laterProfile := npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")) + laterProfile.Steps[0].ID = "seed" + laterProfile.Steps[0].Artifacts["seed"] = laterProfile.Steps[0].Artifacts["npcs"] + delete(laterProfile.Steps[0].Artifacts, "npcs") + laterProfile.Steps = append(laterProfile.Steps, pipeline.PipelineStepProfile{ID: "future", Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)}, + }}) + laterProfile.Steps[1].References["npcs"] = pipeline.GeneratedReference("future", "npcs") + tests := []struct { + name string + profile pipeline.PipelineProfile + want string + }{ + {name: "missing", profile: npcInteractionProfile(pipeline.ReferenceSource{}), want: "source must not be empty"}, + {name: "same step", profile: npcInteractionProfile(pipeline.GeneratedReference("interactions", "interactions")), want: "earlier step"}, + {name: "later step", profile: laterProfile, want: "earlier step"}, + {name: "wrong artifact kind", profile: npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), want: "does not accept artifact kind"}, + } + tests[3].profile.Steps[0].Artifacts["npcs"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("dnd/spells")} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := pipeline.ResolvePipeline(test.profile, pipeline.ResolveOptions{}, catalog) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestProductionNPCInteractionReferencesRejectIncompatibleExternalRegistries(t *testing.T) { + components := productionTestComponents(t) + catalog := catalogFromRegistries(components.registries) + root := t.TempDir() + for _, test := range []struct { + name string + file string + content string + prepare bool + want string + }{ + {name: "media type", file: "registry.txt", content: "not JSON", want: "media type"}, + {name: "artifact schema", file: "registry.json", content: `{"npcs":[{"name":"missing required fields"}]}`, prepare: true, want: "NPC registry"}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(root, test.file) + if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog) + if err != nil { + t.Fatalf("ResolvePipeline() error = %v", err) + } + materialized, _, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{}) + if !test.prepare { + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("MaterializeReferences() error = %v, want %q", err, test.want) + } + return + } + if err != nil { + t.Fatalf("MaterializeReferences() error = %v", err) + } + if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Prepare() error = %v, want %q", err, test.want) + } + }) + } +} + +func npcInteractionProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile { + profile := pipeline.PipelineProfile{ + ID: "dnd-npc-interactions", + Input: pipeline.Binding("seriatim"), + Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, + Output: pipeline.Binding("json"), + Steps: []pipeline.PipelineStepProfile{ + {ID: "npcs", Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)}, + }}, + {ID: "interactions", References: map[string]pipeline.ReferenceSource{"npcs": reference}, Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "interactions": {Extract: pipeline.Binding(interactionextract.Key), Normalize: pipeline.Binding(interactionnormalize.Key)}, + }}, + }, + } + return profile +} diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index dfb1a75..fbbbe2d 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -3,15 +3,22 @@ package register import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants" combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants" + interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" + interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs" + interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness" npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs" @@ -108,5 +115,34 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { }, }) }}, + {name: "npc interactions validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, + Module: interactionextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(interactionshape.Key), + pipeline.Binding(interactionregistry.Key), + pipeline.Binding(interactionrefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(interactionrelatedness.Key), + }, + }) + }}, + {name: "npc interactions normalize validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: interactionnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(interactionshape.Key), + pipeline.Binding(interactionregistry.Key), + pipeline.Binding(interactioninvariants.Key), + pipeline.Binding(interactionrefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(interactionrelatedness.Key), + }, + }) + }}, }) } diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 2ac216c..2d2366c 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -6,12 +6,15 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" + interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" @@ -24,10 +27,12 @@ func registerModules(registries pipeline.Registries) error { {name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }}, {name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }}, {name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }}, + {name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }}, {name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }}, {name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }}, {name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }}, {name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }}, + {name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }}, {name: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, @@ -37,9 +42,13 @@ func registerModules(registries pipeline.Registries) error { {name: "combat-turn-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists) }}, + {name: "npc-interaction-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists) + }}, {name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }}, {name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }}, {name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }}, + {name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, @@ -49,6 +58,9 @@ func registerModules(registries pipeline.Registries) error { {name: "combat-turn-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind) }}, + {name: "npc-interaction-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind) + }}, }) } @@ -58,5 +70,6 @@ func registerPromptAssets(assets *llm.AssetRegistry) error { {name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }}, {name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }}, {name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }}, + {name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }}, }) } diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 3e6e6d1..0f10069 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -13,9 +13,11 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) @@ -27,13 +29,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) { t.Fatalf("Register() error = %v, want nil", err) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) - assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, interactionextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/npcs/shape", "extract/dnd/npcs/source_refs", @@ -47,6 +50,11 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "extract/dnd/combat-turns/source_refs", "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", + "extract/dnd/npc-interactions/shape", + "extract/dnd/npc-interactions/registry", + "extract/dnd/npc-interactions/source_refs", + "extract/dnd/npc-interactions/source_relatedness", + "normalize/dnd/npc-interactions/invariants", "generic/always_accept", "generic/always_reject", }) @@ -106,9 +114,35 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain) } + interactionExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/npc-interactions/shape"), + pipeline.Binding("extract/dnd/npc-interactions/registry"), + pipeline.Binding("extract/dnd/npc-interactions/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"), + } + interactionNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/npc-interactions/shape"), + pipeline.Binding("extract/dnd/npc-interactions/registry"), + pipeline.Binding("normalize/dnd/npc-interactions/invariants"), + pipeline.Binding("extract/dnd/npc-interactions/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, interactionextract.Key); !reflect.DeepEqual(got, interactionExtractChain) { + t.Fatalf("NPC interaction extract validator chain = %#v, want %#v", got, interactionExtractChain) + } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, interactionnormalize.Key); !reflect.DeepEqual(got, interactionNormalizeChain) { + t.Fatalf("NPC interaction normalize validator chain = %#v, want %#v", got, interactionNormalizeChain) + } if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil { t.Fatalf("NPC merge validator chain = %#v, want absent", got) } + if got := registries.ValidatorChains.Validators(pipeline.StageMerge, interactionextract.Key); got != nil { + t.Fatalf("NPC interaction merge validator chain = %#v, want absent", got) + } assertAssetNamesContain(t, assets.PromptFS, []string{ "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/instructions.md", @@ -134,12 +168,17 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.combat_turns/sharedassets/common-dnd-system.md", "dnd.combat_turns/sharedassets/common-dnd-transcript.md", "dnd.combat_turns/task.md", + "dnd.npc_interactions/dnd.npc_interactions.yaml", + "dnd.npc_interactions/instructions.md", + "dnd.npc_interactions/sharedassets/common-dnd-npcs.md", + "dnd.npc_interactions/task.md", }) assertAssetNamesContain(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", "dnd_spells_llm.v1.json", "dnd_npcs_llm.v1.json", "dnd_combat_turns_llm.v1.json", + "dnd_npc_interactions_llm.v1.json", }) if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" { t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok) @@ -162,6 +201,25 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok) } + interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key) + interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key) + if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { + t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK) + } + extractRegistrySlot := referenceSlot(interactionExtractSpec.ReferenceSlots, "npcs") + normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs") + if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !reflect.DeepEqual(extractRegistrySlot, normalizeRegistrySlot) { + t.Fatalf("NPC interaction registry slots disagree: %#v / %#v", interactionExtractSpec.ReferenceSlots, interactionNormalizeSpec.ReferenceSlots) + } +} + +func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot { + for _, slot := range slots { + if slot.Name == name { + return slot + } + } + return contracts.ReferenceSlot{} } func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index 9bf11aa..a0d9cd6 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -7,6 +7,11 @@ import ( combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants" + interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" + interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs" + interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness" npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs" @@ -33,6 +38,11 @@ func registerValidators(registries pipeline.Registries) error { {name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }}, {name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }}, {name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }}, + {name: "npc interaction shape validator", register: func() error { return interactionshape.Register(registries.Validators) }}, + {name: "npc interaction registry validator", register: func() error { return interactionregistry.Register(registries.Validators) }}, + {name: "npc interaction source references validator", register: func() error { return interactionrefs.Register(registries.Validators) }}, + {name: "npc interaction source relatedness validator", register: func() error { return interactionrelatedness.Register(registries.Validators) }}, + {name: "npc interaction normalized invariants validator", register: func() error { return interactioninvariants.Register(registries.Validators) }}, {name: "spell-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, @@ -51,5 +61,11 @@ func registerValidators(registries pipeline.Registries) error { {name: "combat-turn-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind) }}, + {name: "npc-interaction-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) + }}, + {name: "npc-interaction-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) + }}, }) }