From 811d5b8bd921893d44f3c3dbfe6d28e53a2b49b9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 4 Aug 2026 00:39:00 +0000 Subject: [PATCH] Compose D&D location tracking modules --- .../locationoccurrences/normalizer.go | 2 +- internal/modules/dnd/register/chains.go | 50 ++++++++++ internal/modules/dnd/register/evidence.go | 22 +++++ internal/modules/dnd/register/merge.go | 54 ++++++++++ internal/modules/dnd/register/modules.go | 29 ++++++ .../modules/dnd/register/register_test.go | 98 +++++++++++++++++-- internal/modules/dnd/register/validators.go | 30 ++++++ .../registry/validator_test.go | 6 +- 8 files changed, 282 insertions(+), 9 deletions(-) diff --git a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go index 4554594..12be344 100644 --- a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go @@ -334,7 +334,7 @@ func occurrenceScope(index int) string { return fmt.Sprintf("occurrences[%d]", i func referenceSlots() []contracts.ReferenceSlot { slots := shared.ReferenceSlots(referenceSlotDescriptions) slots = append(slots, contracts.ReferenceSlot{ - Name: LocationRegistryReferenceSlot, Description: "Required normalized location registry used only for occurrence identity grounding, never as occurrence evidence.", + Name: LocationRegistryReferenceSlot, Description: "Required normalized location registry used only for location identity grounding, never as occurrence evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.LocationListKind}, MaxBytes: LocationRegistryMaxBytes, }) sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name }) diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index cc3a176..55fd897 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -5,6 +5,8 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" + locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" @@ -12,6 +14,8 @@ import ( combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" + locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" + locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -29,6 +33,15 @@ import ( itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" + occurrenceinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/invariants" + occurrenceregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/registry" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" + occurrencerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/source_refs" + occurrencerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/source_relatedness" + locationidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/identity" + locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/shape" + locationrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/source_refs" + locationrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/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" @@ -245,5 +258,42 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { }, }) }}, + {name: "locations validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, Module: locationextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), pipeline.Binding(locationshape.Key), pipeline.Binding(locationrefs.Key), + pipeline.Binding(validjsonschema.Key), pipeline.Binding(locationrelatedness.Key), + }, + }) + }}, + {name: "locations normalize validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, Module: locationnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), pipeline.Binding(locationshape.Key), pipeline.Binding(locationidentity.Key), + pipeline.Binding(locationrefs.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(locationrelatedness.Key), + }, + }) + }}, + {name: "location occurrences validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, Module: locationoccurrenceextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), pipeline.Binding(occurrenceshape.Key), pipeline.Binding(occurrenceregistry.Key), + pipeline.Binding(occurrencerefs.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(occurrencerelatedness.Key), + }, + }) + }}, + {name: "location occurrences normalize validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, Module: locationoccurrencenormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), pipeline.Binding(occurrenceshape.Key), pipeline.Binding(occurrenceregistry.Key), + pipeline.Binding(occurrenceinvariants.Key), pipeline.Binding(occurrencerefs.Key), pipeline.Binding(validjsonschema.Key), + pipeline.Binding(occurrencerelatedness.Key), + }, + }) + }}, }) } diff --git a/internal/modules/dnd/register/evidence.go b/internal/modules/dnd/register/evidence.go index 9dd6474..ffceafa 100644 --- a/internal/modules/dnd/register/evidence.go +++ b/internal/modules/dnd/register/evidence.go @@ -25,6 +25,12 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error { {name: "scene descriptions evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence) }}, + {name: "locations evidence", register: func() error { + return pipeline.RegisterArtifactEvidence(registry, dnd.LocationListKind, locationEvidence) + }}, + {name: "location occurrences evidence", register: func() error { + return pipeline.RegisterArtifactEvidence(registry, dnd.LocationOccurrenceListKind, locationOccurrenceEvidence) + }}, }) } @@ -83,3 +89,19 @@ func sceneDescriptionEvidence(value dnd.SceneDescriptionList) []source.SourceRef } return refs } + +func locationEvidence(value dnd.LocationList) []source.SourceRef { + var refs []source.SourceRef + for _, location := range value.Locations { + refs = append(refs, location.SourceRefs...) + } + return append([]source.SourceRef(nil), refs...) +} + +func locationOccurrenceEvidence(value dnd.LocationOccurrenceList) []source.SourceRef { + var refs []source.SourceRef + for _, occurrence := range value.Occurrences { + refs = append(refs, occurrence.SourceRefs...) + } + return append([]source.SourceRef(nil), refs...) +} diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index af475ae..1475543 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -152,6 +152,48 @@ func appendSceneDescriptionLists(values []dnd.SceneDescriptionList) (dnd.SceneDe return combined, nil } +func appendLocationLists(values []dnd.LocationList) (dnd.LocationList, error) { + count := 0 + present := false + for _, value := range values { + if value.Locations != nil { + present = true + } + count += len(value.Locations) + } + if !present { + return dnd.LocationList{}, nil + } + combined := dnd.LocationList{Locations: make([]dnd.Location, 0, count)} + for _, value := range values { + for _, location := range value.Locations { + combined.Locations = append(combined.Locations, cloneLocation(location)) + } + } + return combined, nil +} + +func appendLocationOccurrenceLists(values []dnd.LocationOccurrenceList) (dnd.LocationOccurrenceList, error) { + count := 0 + present := false + for _, value := range values { + if value.Occurrences != nil { + present = true + } + count += len(value.Occurrences) + } + if !present { + return dnd.LocationOccurrenceList{}, nil + } + combined := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, 0, count)} + for _, value := range values { + for _, occurrence := range value.Occurrences { + combined.Occurrences = append(combined.Occurrences, cloneLocationOccurrence(occurrence)) + } + } + return combined, nil +} + func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { clone := value clone.SourceRefs = cloneSourceRefs(value.SourceRefs) @@ -192,6 +234,18 @@ func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction { return clone } +func cloneLocation(value dnd.Location) dnd.Location { + clone := value + clone.SourceRefs = cloneSourceRefs(value.SourceRefs) + return clone +} + +func cloneLocationOccurrence(value dnd.LocationOccurrence) dnd.LocationOccurrence { + clone := value + clone.SourceRefs = cloneSourceRefs(value.SourceRefs) + return clone +} + func cloneSourceRefs(refs []source.SourceRef) []source.SourceRef { return slices.Clone(refs) } diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index c5d1c9b..b7dd6d5 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -8,6 +8,8 @@ import ( combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents" itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents" + locationoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationoccurrences" + locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locations" interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" @@ -15,6 +17,8 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" + locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" @@ -22,6 +26,8 @@ import ( combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" + locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" + locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -43,6 +49,10 @@ func registerModules(registries pipeline.Registries) error { {name: "scene descriptions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, scenedescriptioncodec.New()) }}, + {name: "locations codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, locationcodec.New()) }}, + {name: "location occurrences codec", register: func() error { + return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, locationoccurrencecodec.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) }}, @@ -51,6 +61,8 @@ func registerModules(registries pipeline.Registries) error { {name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }}, {name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }}, {name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }}, + {name: "locations extractor", register: func() error { return locationextract.Register(registries.Extractors) }}, + {name: "location occurrences extractor", register: func() error { return locationoccurrenceextract.Register(registries.Extractors) }}, {name: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, @@ -72,6 +84,12 @@ func registerModules(registries pipeline.Registries) error { {name: "scene-description-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists) }}, + {name: "location-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.LocationListKind, appendLocationLists) + }}, + {name: "location-occurrence-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.LocationOccurrenceListKind, appendLocationOccurrenceLists) + }}, {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) }}, @@ -79,6 +97,8 @@ func registerModules(registries pipeline.Registries) error { {name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }}, {name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }}, {name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }}, + {name: "locations normalizer", register: func() error { return locationnormalize.Register(registries.Normalizers) }}, + {name: "location occurrences normalizer", register: func() error { return locationoccurrencenormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, @@ -100,6 +120,12 @@ func registerModules(registries pipeline.Registries) error { {name: "scene-description-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind) }}, + {name: "location-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.LocationList](registries.Normalizers, dnd.LocationListKind) + }}, + {name: "location-occurrence-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.LocationOccurrenceList](registries.Normalizers, dnd.LocationOccurrenceListKind) + }}, }) } @@ -115,5 +141,8 @@ func registerPromptAssets(assets *llm.AssetRegistry) error { {name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }}, {name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }}, {name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }}, + {name: "locations prompt assets", register: func() error { return locationextract.RegisterPromptAssets(assets) }}, + {name: "location normalization prompt assets", register: func() error { return locationnormalize.RegisterPromptAssets(assets) }}, + {name: "location occurrences prompt assets", register: func() error { return locationoccurrenceextract.RegisterPromptAssets(assets) }}, }) } diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 844c9ce..805cb03 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -15,6 +15,8 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" + locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locations" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" @@ -22,6 +24,8 @@ import ( combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" + locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" + locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locations" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -51,6 +55,9 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.npc_interactions/dnd.npc_interactions.yaml", "dnd.scene_descriptions/dnd.scene_descriptions.yaml", "dnd.npcs.normalize/dnd.npcs.normalize.yaml", + "dnd.locations/dnd.locations.yaml", + "dnd.locations.normalize/dnd.locations.normalize.yaml", + "dnd.location_occurrences/dnd.location_occurrences.yaml", } { content, err := fs.ReadFile(promptFS, name) if err != nil { @@ -75,12 +82,12 @@ func TestRegisterAddsDNDFamily(t *testing.T) { t.Fatalf("entity reconciliation schema asset = %v, want registered shared schema", err) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) - assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) - assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) 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(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) @@ -88,6 +95,8 @@ func TestRegisterAddsDNDFamily(t *testing.T) { assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ + "extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness", + "extract/dnd/location-occurrences/shape", "extract/dnd/location-occurrences/registry", "normalize/dnd/location-occurrences/invariants", "extract/dnd/location-occurrences/source_refs", "extract/dnd/location-occurrences/source_relatedness", "extract/dnd/npcs/shape", "extract/dnd/npcs/source_refs", "extract/dnd/npcs/source_relatedness", @@ -121,6 +130,22 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "generic/always_accept", "generic/always_reject", }) + locationExtractChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/locations/shape"), pipeline.Binding("extract/dnd/locations/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/locations/source_relatedness")} + locationNormalizeChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/locations/shape"), pipeline.Binding("normalize/dnd/locations/identity"), pipeline.Binding("extract/dnd/locations/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/locations/source_relatedness")} + occurrenceExtractChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-occurrences/shape"), pipeline.Binding("extract/dnd/location-occurrences/registry"), pipeline.Binding("extract/dnd/location-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-occurrences/source_relatedness")} + occurrenceNormalizeChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-occurrences/shape"), pipeline.Binding("extract/dnd/location-occurrences/registry"), pipeline.Binding("normalize/dnd/location-occurrences/invariants"), pipeline.Binding("extract/dnd/location-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-occurrences/source_relatedness")} + for _, test := range []struct { + stage pipeline.ModuleStage + key string + want []pipeline.ModuleBinding + }{ + {pipeline.StageExtract, locationextract.Key, locationExtractChain}, {pipeline.StageNormalize, locationnormalize.Key, locationNormalizeChain}, + {pipeline.StageExtract, locationoccurrenceextract.Key, occurrenceExtractChain}, {pipeline.StageNormalize, locationoccurrencenormalize.Key, occurrenceNormalizeChain}, + } { + if got := registries.ValidatorChains.Validators(test.stage, test.key); !reflect.DeepEqual(got, test.want) { + t.Fatalf("validator chain for %s/%s = %#v, want %#v", test.stage, test.key, got, test.want) + } + } wantChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/spells/shape"), @@ -334,6 +359,8 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd_item_events_llm.v1.json", "dnd_npc_interactions_llm.v1.json", "dnd_scene_descriptions_llm.v1.json", + "dnd_locations_llm.v1.json", + "dnd_location_occurrences_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) @@ -384,6 +411,21 @@ func TestRegisterAddsDNDFamily(t *testing.T) { 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) } + locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key) + locationNormalizeSpec, locationNormalizeOK := registries.Normalizers.Spec(locationnormalize.Key) + if !locationExtractOK || locationExtractSpec.ArtifactKind != dnd.LocationListKind || locationExtractSpec.ExecutionClass != contracts.ExecutionClassLLMBacked || !locationNormalizeOK || locationNormalizeSpec.ArtifactKind != dnd.LocationListKind || locationNormalizeSpec.ExecutionClass != contracts.ExecutionClassLLMBacked { + t.Fatalf("location specs = %#v / %#v", locationExtractSpec, locationNormalizeSpec) + } + occurrenceExtractSpec, occurrenceExtractOK := registries.Extractors.Spec(locationoccurrenceextract.Key) + occurrenceNormalizeSpec, occurrenceNormalizeOK := registries.Normalizers.Spec(locationoccurrencenormalize.Key) + if !occurrenceExtractOK || occurrenceExtractSpec.ArtifactKind != dnd.LocationOccurrenceListKind || occurrenceExtractSpec.ExecutionClass != contracts.ExecutionClassLLMBacked || !occurrenceNormalizeOK || occurrenceNormalizeSpec.ArtifactKind != dnd.LocationOccurrenceListKind || occurrenceNormalizeSpec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("location occurrence specs = %#v / %#v", occurrenceExtractSpec, occurrenceNormalizeSpec) + } + locationRegistrySlot := referenceSlot(occurrenceExtractSpec.ReferenceSlots, "locations") + occurrenceNormalizeRegistrySlot := referenceSlot(occurrenceNormalizeSpec.ReferenceSlots, "locations") + if !locationRegistrySlot.Required || !reflect.DeepEqual(locationRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.LocationListKind}) || !reflect.DeepEqual(locationRegistrySlot, occurrenceNormalizeRegistrySlot) { + t.Fatalf("location registry slots disagree: %#v / %#v", occurrenceExtractSpec.ReferenceSlots, occurrenceNormalizeSpec.ReferenceSlots) + } sceneExtractSpec, sceneExtractOK := registries.Extractors.Spec(scenedescriptionextract.Key) sceneNormalizeSpec, sceneNormalizeOK := registries.Normalizers.Spec(scenedescriptionnormalize.Key) if !sceneExtractOK || sceneExtractSpec.ArtifactKind != dnd.SceneDescriptionListKind || !sceneNormalizeOK || sceneNormalizeSpec.ArtifactKind != dnd.SceneDescriptionListKind || sceneNormalizeSpec.Stage != pipeline.StageNormalize { @@ -428,6 +470,12 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes {name: "scene descriptions", project: func() []source.SourceRef { return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}}) }, want: []source.SourceRef{first, second}}, + {name: "locations", project: func() []source.SourceRef { + return locationEvidence(dnd.LocationList{Locations: []dnd.Location{{SourceRefs: []source.SourceRef{first, second}}}}) + }, want: []source.SourceRef{first, second}}, + {name: "location occurrences", project: func() []source.SourceRef { + return locationOccurrenceEvidence(dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) + }, want: []source.SourceRef{first, second}}, } { t.Run(test.name, func(t *testing.T) { got := test.project() @@ -482,6 +530,44 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { } } +func TestAppendLocationListsPreserveOrderPresenceAndOwnership(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + input := []dnd.LocationList{{Locations: []dnd.Location{{ID: "one", Name: "First", SourceRefs: refs}}}, {Locations: []dnd.Location{{ID: "two", Name: "Second", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}} + got, err := appendLocationLists(input) + if err != nil || !reflect.DeepEqual([]string{got.Locations[0].Name, got.Locations[1].Name}, []string{"First", "Second"}) { + t.Fatalf("appendLocationLists() = %#v, %v", got, err) + } + got.Locations[0].SourceRefs[0].StartUnitID = 99 + if input[0].Locations[0].SourceRefs[0].StartUnitID != 1 { + t.Fatal("merged locations share source-reference storage") + } + for _, values := range [][]dnd.LocationList{nil, {{}, {}}} { + result, err := appendLocationLists(values) + if err != nil || result.Locations != nil { + t.Fatalf("nil-only merge = %#v, %v", result, err) + } + } +} + +func TestAppendLocationOccurrenceListsPreserveOrderPresenceAndOwnership(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + input := []dnd.LocationOccurrenceList{{Occurrences: []dnd.LocationOccurrence{{LocationID: "one", Name: "First", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: refs}}}, {Occurrences: []dnd.LocationOccurrence{{LocationID: "two", Name: "Second", Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}} + got, err := appendLocationOccurrenceLists(input) + if err != nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"First", "Second"}) { + t.Fatalf("appendLocationOccurrenceLists() = %#v, %v", got, err) + } + got.Occurrences[0].SourceRefs[0].StartUnitID = 99 + if input[0].Occurrences[0].SourceRefs[0].StartUnitID != 1 { + t.Fatal("merged location occurrences share source-reference storage") + } + for _, values := range [][]dnd.LocationOccurrenceList{nil, {{}, {}}} { + result, err := appendLocationOccurrenceLists(values) + if err != nil || result.Occurrences != nil { + t.Fatalf("nil-only merge = %#v, %v", result, err) + } + } +} + func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) { tests := []struct { name string diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index 295cc83..b5e833b 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -16,6 +16,15 @@ import ( itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" + occurrenceinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/invariants" + occurrenceregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/registry" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" + occurrencerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/source_refs" + occurrencerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/source_relatedness" + locationidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/identity" + locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/shape" + locationrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/source_refs" + locationrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/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" @@ -69,6 +78,15 @@ func registerValidators(registries pipeline.Registries) error { {name: "scene description source references validator", register: func() error { return scenerefs.Register(registries.Validators) }}, {name: "scene description source relatedness validator", register: func() error { return scenerelatedness.Register(registries.Validators) }}, {name: "scene description normalized invariants validator", register: func() error { return sceneinvariants.Register(registries.Validators) }}, + {name: "location shape validator", register: func() error { return locationshape.Register(registries.Validators) }}, + {name: "location identity validator", register: func() error { return locationidentity.Register(registries.Validators) }}, + {name: "location source references validator", register: func() error { return locationrefs.Register(registries.Validators) }}, + {name: "location source relatedness validator", register: func() error { return locationrelatedness.Register(registries.Validators) }}, + {name: "location occurrence shape validator", register: func() error { return occurrenceshape.Register(registries.Validators) }}, + {name: "location occurrence registry validator", register: func() error { return occurrenceregistry.Register(registries.Validators) }}, + {name: "location occurrence normalized invariants validator", register: func() error { return occurrenceinvariants.Register(registries.Validators) }}, + {name: "location occurrence source references validator", register: func() error { return occurrencerefs.Register(registries.Validators) }}, + {name: "location occurrence source relatedness validator", register: func() error { return occurrencerelatedness.Register(registries.Validators) }}, {name: "spell-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, @@ -111,5 +129,17 @@ func registerValidators(registries pipeline.Registries) error { {name: "scene-description-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind) }}, + {name: "location-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.LocationList](registries.Validators, dnd.LocationListKind) + }}, + {name: "location-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.LocationList](registries.Validators, dnd.LocationListKind) + }}, + {name: "location-occurrence-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.LocationOccurrenceList](registries.Validators, dnd.LocationOccurrenceListKind) + }}, + {name: "location-occurrence-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.LocationOccurrenceList](registries.Validators, dnd.LocationOccurrenceListKind) + }}, }) } diff --git a/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go index 0f368ee..cc8264a 100644 --- a/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go +++ b/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go @@ -27,7 +27,7 @@ func TestValidatorDistinguishesSameNameLocationsByID(t *testing.T) { if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { t.Fatalf("matching IDs = %#v, %v", result, err) } - value.Occurrences[0].Name = "Other Gate" + value.Occurrences[0].Name = "Sun Gate" result, err = validator.Validate(context.Background(), request(references, value)) if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match registry location") { t.Fatalf("mismatched name = %#v, %v", result, err) @@ -101,7 +101,9 @@ func registryReferences(t *testing.T) (contracts.ReferenceSet, dnd.Location, dnd secondRefs := []source.SourceRef{{SourceID: "source", StartUnitID: 2, EndUnitID: 2}} first := dnd.Location{ID: identity.DeriveID("Moon Gate", firstRefs), Name: "Moon Gate", SourceRefs: firstRefs} second := dnd.Location{ID: identity.DeriveID("Moon Gate", secondRefs), Name: "Moon Gate", SourceRefs: secondRefs} - content, err := locationcodec.New().Encode(dnd.LocationList{Locations: []dnd.Location{first, second}}) + thirdRefs := []source.SourceRef{{SourceID: "source", StartUnitID: 3, EndUnitID: 3}} + third := dnd.Location{ID: identity.DeriveID("Sun Gate", thirdRefs), Name: "Sun Gate", SourceRefs: thirdRefs} + content, err := locationcodec.New().Encode(dnd.LocationList{Locations: []dnd.Location{first, second, third}}) if err != nil { t.Fatal(err) }