2026-07-21 02:36:59 +00:00
// 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"
2026-07-21 04:34:56 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
2026-07-21 02:36:59 +00:00
)
const (
Key = "dnd/npcs"
2026-07-21 14:58:26 +00:00
normalizationPolicy = "dnd.npcs.normalize.v1"
NormalizationPolicy = normalizationPolicy
2026-07-21 02:36:59 +00:00
2026-07-22 18:51:26 +00:00
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
2026-07-21 02:36:59 +00:00
)
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 { }
2026-07-22 18:51:26 +00:00
func New ( Options ) * Normalizer { return & Normalizer { } }
func ( n * Normalizer ) Key ( ) string { return Key }
2026-07-21 02:36:59 +00:00
func ( n * Normalizer ) ReferenceSlots ( ) [ ] contracts . ReferenceSlot { return nil }
func ( n * Normalizer ) ManifestMetadata ( ) map [ string ] any {
if n == nil {
return nil
}
2026-07-22 18:51:26 +00:00
return map [ string ] any { "identity_policy" : identity . Policy , "normalization_policy" : normalizationPolicy }
2026-07-21 02:36:59 +00:00
}
func ( n * Normalizer ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
if n == nil {
return nil
}
return [ ] pipeline . CheckpointFingerprint {
{ Name : "identity_policy" , Value : identity . Policy } ,
2026-07-21 14:58:26 +00:00
{ Name : "normalization_policy" , Value : normalizationPolicy } ,
2026-07-21 02:36:59 +00:00
}
}
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 {
2026-07-22 18:51:26 +00:00
npc dnd . NPC
2026-07-21 02:36:59 +00:00
}
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 )
2026-07-22 18:51:26 +00:00
records [ index ] = normalizedRecord { npc : npc }
2026-07-21 02:36:59 +00:00
if fieldsChanged {
2026-07-22 18:51:26 +00:00
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 ) ) } )
2026-07-21 02:36:59 +00:00
}
if referencesChanged {
2026-07-22 18:51:26 +00:00
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 ) ) } )
2026-07-21 02:36:59 +00:00
}
if inputNPC . ID != npc . ID {
2026-07-22 18:51:26 +00:00
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 ) ) } )
2026-07-21 02:36:59 +00:00
}
}
2026-07-22 18:51:26 +00:00
groups := canonicalNameGroups ( records )
output := dnd . NPCList { NPCs : make ( [ ] dnd . NPC , 0 , len ( groups ) ) }
for _ , members := range groups {
consolidated , referencesChanged := consolidate ( records , members )
2026-07-21 02:36:59 +00:00
retainedIndex := members [ 0 ]
output . NPCs = append ( output . NPCs , consolidated )
2026-07-22 18:51:26 +00:00
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 ) ) } )
2026-07-21 02:36:59 +00:00
}
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 )
2026-07-22 18:51:26 +00:00
return output , input . Name != output . Name , ! reflect . DeepEqual ( input . SourceRefs , output . SourceRefs )
2026-07-21 02:36:59 +00:00
}
func cloneNPC ( input dnd . NPC ) dnd . NPC {
2026-07-22 18:51:26 +00:00
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
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
ownerByKey [ key ] = len ( groups )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
groups = append ( groups , [ ] int { index } )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
return groups
2026-07-21 02:36:59 +00:00
}
func consolidate ( records [ ] normalizedRecord , members [ ] int ) ( dnd . NPC , bool ) {
output := cloneNPC ( records [ members [ 0 ] ] . npc )
originalRefs := cloneSourceRefs ( output . SourceRefs )
for _ , member := range members [ 1 : ] {
2026-07-22 18:51:26 +00:00
output . SourceRefs = append ( output . SourceRefs , records [ member ] . npc . SourceRefs ... )
2026-07-21 02:36:59 +00:00
}
output . SourceRefs , _ , _ = canonicalizeSourceRefs ( output . SourceRefs )
output . ID = identity . DeriveID ( output . Name )
return output , ! reflect . DeepEqual ( originalRefs , output . SourceRefs )
}
2026-07-22 18:51:26 +00:00
func canonicalizeSourceRefs ( input [ ] source . SourceRef ) ( [ ] source . SourceRef , bool , int ) {
2026-07-21 02:36:59 +00:00
if input == nil {
2026-07-22 18:51:26 +00:00
return nil , false , 0
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
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
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
if canonical [ left ] . StartUnitID != canonical [ right ] . StartUnitID {
return canonical [ left ] . StartUnitID < canonical [ right ] . StartUnitID
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
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 )
2026-07-21 02:36:59 +00:00
}
}
2026-07-22 18:51:26 +00:00
return unique , orderChanged , len ( input ) - len ( unique )
2026-07-21 02:36:59 +00:00
}
2026-07-22 18:51:26 +00:00
func cloneSourceRefs ( input [ ] source . SourceRef ) [ ] source . SourceRef {
2026-07-21 02:36:59 +00:00
if input == nil {
return nil
}
2026-07-22 18:51:26 +00:00
return append ( [ ] source . SourceRef ( nil ) , input ... )
2026-07-21 02:36:59 +00:00
}
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 )
}
2026-07-22 18:51:26 +00:00
return contracts . Warning { Scope : npcScope ( retainedIndex ) , ReasonCode : ReasonCodeDuplicateNPCCollapsed , Message : message }
2026-07-21 02:36:59 +00:00
}
func npcScope ( index int ) string { return fmt . Sprintf ( "npcs[%d]" , index ) }
func ModuleSpec ( ) pipeline . ModuleSpec {
2026-07-22 18:51:26 +00:00
return pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , Requires : append ( [ ] string ( nil ) , requiredCapabilities ... ) , Provides : append ( [ ] string ( nil ) , providedCapabilities ... ) , ArtifactKind : dnd . NPCListKind }
2026-07-21 02:36:59 +00:00
}
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
} )
}
2026-07-22 18:51:26 +00:00
func validateOptions ( options map [ string ] any ) error { _ , err := DecodeOptions ( options ) ; return err }
2026-07-21 02:36:59 +00:00
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 ... )
}