// Package npcs normalizes merged D&D non-player character records. package npcs import ( "context" "fmt" "reflect" "sort" "strconv" "strings" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) const ( Key = "dnd/npcs" normalizationPolicy = "dnd.npcs.normalize.v1" NormalizationPolicy = normalizationPolicy ReasonCodeNPCFieldsNormalized = "npc_fields_normalized" ReasonCodeNPCIDRecomputed = "npc_id_recomputed" ReasonCodeSourceReferencesNormalized = "source_references_normalized" ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed" ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.NPCList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct{} func New(Options) *Normalizer { return &Normalizer{} } func (n *Normalizer) Key() string { return Key } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ManifestMetadata() map[string]any { if n == nil { return nil } return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy} } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } return []pipeline.CheckpointFingerprint{ {Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, } } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCList]) (contracts.TypedNormalizeResult[dnd.NPCList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err) } value, warnings := normalizeList(req.MergeOutput.Value) return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { npc dnd.NPC } func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) { if input.NPCs == nil { return dnd.NPCList{}, nil } records := make([]normalizedRecord, len(input.NPCs)) warnings := make([]contracts.Warning, 0) for index, inputNPC := range input.NPCs { npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC) records[index] = normalizedRecord{npc: npc} if fieldsChanged { warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))}) } if referencesChanged { warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))}) } if inputNPC.ID != npc.ID { warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))}) } } groups := canonicalNameGroups(records) output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))} for _, members := range groups { consolidated, referencesChanged := consolidate(records, members) retainedIndex := members[0] output.NPCs = append(output.NPCs, consolidated) if referencesChanged { warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.SourceRefs))}) } if len(members) > 1 { warnings = append(warnings, duplicateWarning(retainedIndex, members[1:])) } } return output, warnings } func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) { output := cloneNPC(input) output.Name = identity.NormalizeDisplay(input.Name) output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs) output.ID = identity.DeriveID(output.Name) return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs) } func cloneNPC(input dnd.NPC) dnd.NPC { input.SourceRefs = cloneSourceRefs(input.SourceRefs) return input } func canonicalNameGroups(records []normalizedRecord) [][]int { groups := make([][]int, 0, len(records)) ownerByKey := make(map[string]int, len(records)) for index, record := range records { key := identity.ComparisonKey(record.npc.Name) if key != "" { if groupIndex, ok := ownerByKey[key]; ok { groups[groupIndex] = append(groups[groupIndex], index) continue } ownerByKey[key] = len(groups) } groups = append(groups, []int{index}) } return groups } func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) { output := cloneNPC(records[members[0]].npc) originalRefs := cloneSourceRefs(output.SourceRefs) for _, member := range members[1:] { output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...) } output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs) output.ID = identity.DeriveID(output.Name) return output, !reflect.DeepEqual(originalRefs, output.SourceRefs) } func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) { if input == nil { return nil, false, 0 } canonical := cloneSourceRefs(input) sort.SliceStable(canonical, func(left, right int) bool { if canonical[left].SourceID != canonical[right].SourceID { return canonical[left].SourceID < canonical[right].SourceID } if canonical[left].StartUnitID != canonical[right].StartUnitID { return canonical[left].StartUnitID < canonical[right].StartUnitID } return canonical[left].EndUnitID < canonical[right].EndUnitID }) orderChanged := !reflect.DeepEqual(input, canonical) 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 cloneSourceRefs(input []source.SourceRef) []source.SourceRef { if input == nil { return nil } return append([]source.SourceRef(nil), input...) } func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { displayed = displayed[:maxDisplayedIndices] } indices := make([]string, len(displayed)) for index, removedIndex := range displayed { indices[index] = strconv.Itoa(removedIndex) } message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", ")) if omitted := len(removed) - len(displayed); omitted > 0 { message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) } return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message} } func npcScope(index int) string { return fmt.Sprintf("npcs[%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.NPCListKind} } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options), nil }) } 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 normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd npcs normalizer: "+format, args...) }