// Package npcinteractions owns canonical ordering and exact-identity rules for // D&D NPC interaction artifacts. package npcinteractions import ( "sort" "strconv" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) // CanonicalizeSourceRefs returns a cloned, document-ordered, de-duplicated // source-reference list. func CanonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef { if input == nil { return nil } canonical := append([]source.SourceRef(nil), input...) sort.SliceStable(canonical, func(left, right int) bool { return SourceRefLess(doc, canonical[left], canonical[right]) }) 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 } // SourceRefsEqual reports whether two source-reference lists have identical // representations and values. func SourceRefsEqual(left, right []source.SourceRef) bool { if (left == nil) != (right == nil) || len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } // SourceRefLess orders references by source identity and then by the source // document positions of their endpoints. Invalid endpoints sort after valid // endpoints and fall back to their literal IDs for deterministic diagnostics. func SourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool { if left.SourceID != right.SourceID { return left.SourceID < right.SourceID } leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID) rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID) if leftStartOK != rightStartOK { return leftStartOK } if leftStartOK && leftStart != rightStart { return leftStart < rightStart } if left.StartUnitID != right.StartUnitID { return left.StartUnitID < right.StartUnitID } leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID) rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID) if leftEndOK != rightEndOK { return leftEndOK } if leftEndOK && leftEnd != rightEnd { return leftEnd < rightEnd } return left.EndUnitID < right.EndUnitID } // Less defines the canonical order for NPC interaction occurrences. func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool { leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left) rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right) if leftHasEvidence != rightHasEvidence { return leftHasEvidence } if leftHasEvidence && leftPosition != rightPosition { return leftPosition < rightPosition } leftKey := identity.ComparisonKey(left.Name) rightKey := identity.ComparisonKey(right.Name) if leftKey != rightKey { return leftKey < rightKey } if left.Name != right.Name { return left.Name < right.Name } if left.Kind != right.Kind { return left.Kind < right.Kind } return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs) } // EarliestSourcePosition returns the earliest valid cited position. func EarliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) { found := false earliest := 0 for _, ref := range interaction.SourceRefs { if source.ValidateRef(doc, ref) != nil { continue } position, ok := source.UnitIndex(doc, ref.StartUnitID) if !ok || (found && position >= earliest) { continue } earliest = position found = true } return earliest, found } // ValidSourceRefs reports whether an interaction has non-empty, valid // current-document evidence. func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool { if len(refs) == 0 { return false } for _, ref := range refs { if source.ValidateRef(doc, ref) != nil { return false } } return true } // ExactIdentity returns a collision-safe key over every durable interaction // field. Callers decide whether the record is eligible for duplicate handling. func ExactIdentity(interaction dnd.NPCInteraction) string { var key strings.Builder writeKeyString(&key, interaction.Name) writeKeyString(&key, string(interaction.Kind)) for _, ref := range interaction.SourceRefs { writeKeyString(&key, ref.SourceID) writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.EndUnitID) } return key.String() } func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool { for index := 0; index < len(left) && index < len(right); index++ { if left[index] == right[index] { continue } return SourceRefLess(doc, left[index], right[index]) } return len(left) < len(right) } func writeKeyString(builder *strings.Builder, value string) { builder.WriteString(strconv.Itoa(len(value))) builder.WriteByte(':') builder.WriteString(value) } func writeKeyInt(builder *strings.Builder, value int) { builder.WriteString(strconv.Itoa(value)) builder.WriteByte(';') }