package npcs import ( "fmt" "sort" "strconv" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) type proposalAssessment struct { safeGroups []safeProposalGroup discardedGroups int issues []string } type safeProposalGroup struct { members []int canonical int } type assessedProposalGroup struct { members []int canonical int locallyValid bool conflicting bool } func assessProposal(response normalizeProposalResponse, records []normalizedRecord, candidatePositions []int) proposalAssessment { positionsByKey := make(map[string][]int, len(candidatePositions)) for _, position := range candidatePositions { if position < 0 || position >= len(records) { continue } key := identity.ComparisonKey(records[position].npc.Name) if key != "" { positionsByKey[key] = append(positionsByKey[key], position) } } groups := make([]assessedProposalGroup, len(response.DuplicateGroups)) issues := make([]string, 0) owners := make(map[int][]int) for groupIndex, proposal := range response.DuplicateGroups { group, groupIssues := assessProposalGroup(proposal, positionsByKey) groups[groupIndex] = group for _, position := range group.members { owners[position] = append(owners[position], groupIndex) } for _, issue := range groupIssues { issues = append(issues, proposalIssue(groupIndex, issue)) } } for groupIndex := range groups { for _, position := range groups[groupIndex].members { if len(owners[position]) > 1 { groups[groupIndex].conflicting = true break } } if groups[groupIndex].conflicting { issues = append(issues, proposalIssue(groupIndex, "overlapping_member")) } } assessment := proposalAssessment{issues: issues} for _, group := range groups { if !group.locallyValid || group.conflicting { assessment.discardedGroups++ continue } assessment.safeGroups = append(assessment.safeGroups, safeProposalGroup{members: group.members, canonical: group.canonical}) } return assessment } func assessProposalGroup(proposal normalizeProposalGroup, positionsByKey map[string][]int) (assessedProposalGroup, []string) { issues := make([]string, 0) members := make([]int, 0, len(proposal.Members)) seenMembers := make(map[int]struct{}, len(proposal.Members)) for _, name := range proposal.Members { position, issue := resolveCandidate(name, positionsByKey) if issue != "" { issues = append(issues, "member_"+issue) continue } if _, exists := seenMembers[position]; exists { issues = append(issues, "repeated_member") continue } seenMembers[position] = struct{}{} members = append(members, position) } canonical, canonicalIssue := resolveCandidate(proposal.CanonicalName, positionsByKey) if canonicalIssue != "" { issues = append(issues, "canonical_"+canonicalIssue) } if len(members) < 2 { issues = append(issues, "fewer_than_two_members") } if canonicalIssue == "" && !containsPosition(members, canonical) { issues = append(issues, "canonical_not_member") } sort.Ints(members) return assessedProposalGroup{ members: members, canonical: canonical, locallyValid: len(issues) == 0, }, issues } func resolveCandidate(name string, positionsByKey map[string][]int) (position int, issue string) { key := identity.ComparisonKey(name) if key == "" { return 0, "blank" } positions := positionsByKey[key] if len(positions) == 0 { return 0, "unknown" } if len(positions) != 1 { return 0, "ambiguous" } return positions[0], "" } func containsPosition(positions []int, want int) bool { for _, position := range positions { if position == want { return true } } return false } func proposalIssue(groupIndex int, category string) string { return "group " + strconv.Itoa(groupIndex) + ": " + category } func applySafeGroups(records []normalizedRecord, groups []safeProposalGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { byMember := make(map[int]safeProposalGroup, len(groups)*2) for _, group := range groups { for _, member := range group.members { byMember[member] = group } } output := make([]normalizedRecord, 0, len(records)-len(groups)) warnings := make([]contracts.Warning, 0, len(groups)) for index, record := range records { group, grouped := byMember[index] if !grouped { output = append(output, cloneRecord(record)) continue } if group.members[0] != index { continue } consolidated := consolidateSemanticGroup(records, group, order) output = append(output, consolidated) warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical])) } return output, warnings } func consolidateSemanticGroup(records []normalizedRecord, group safeProposalGroup, order shared.SourceRefOrder) normalizedRecord { output := cloneRecord(records[group.members[0]]) output.npc.Name = records[group.canonical].npc.Name for _, member := range group.members[1:] { output.npc.SourceRefs = append(output.npc.SourceRefs, records[member].npc.SourceRefs...) output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...) if records[member].earliest < output.earliest { output.earliest = records[member].earliest } } output.inputIndexes = sortedUniqueIndexes(output.inputIndexes) output.npc.SourceRefs = order.Canonicalize(output.npc.SourceRefs) output.npc.ID = identity.DeriveID(output.npc.Name) return output } func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning { details := make([]string, 0, len(record.inputIndexes)+1) for _, inputIndex := range record.inputIndexes { details = append(details, fmt.Sprintf("input index %d", inputIndex)) } if canonical.earliest != record.earliest { details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) } return contracts.Warning{ Scope: npcScope(record.earliest), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details), } }