Files
notarius/internal/modules/dnd/npcinteractions/canonical.go

167 lines
5.0 KiB
Go

// 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(';')
}