// Package itemoccurrences owns canonical ordering and durable domain rules for D&D // item-occurrence artifacts. package itemoccurrences import ( "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/shared" ) const partyHolder = "party" // SupportedKind reports whether kind is one of the durable item occurrence kinds. func SupportedKind(kind dnd.ItemOccurrenceKind) bool { switch kind { case dnd.ItemOccurrenceKindDiscovered, dnd.ItemOccurrenceKindAcquired, dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed, dnd.ItemOccurrenceKindTransferred: return true default: return false } } // DisplayValue returns the durable display form used for item and holder // comparisons without changing the observed internal spelling. func DisplayValue(value string) string { return strings.TrimSpace(value) } // ComparisonKey returns the shared D&D Unicode- and case-insensitive key for // a trimmed item or holder display value. func ComparisonKey(value string) string { return shared.ComparisonKey(DisplayValue(value)) } // HolderPresent reports whether value is a nonblank optional holder. func HolderPresent(value string) bool { return DisplayValue(value) != "" } // IsPartyHolder reports whether value denotes collective party possession. func IsPartyHolder(value string) bool { return ComparisonKey(value) == partyHolder } // ValidHolderCombination reports whether the optional holder fields satisfy // the durable rules for kind. Blank holders are treated as absent so callers // can preserve invalid extraction candidates for their owning validators. func ValidHolderCombination(kind dnd.ItemOccurrenceKind, from, to string) bool { hasFrom := HolderPresent(from) hasTo := HolderPresent(to) switch kind { case dnd.ItemOccurrenceKindDiscovered: return !hasFrom && !hasTo case dnd.ItemOccurrenceKindAcquired: return !hasFrom && hasTo case dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed: return hasFrom && !hasTo case dnd.ItemOccurrenceKindTransferred: return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to) default: return false } } // SourceRefsEqual reports whether two source-reference sequences have the // same representation and values, including nil-versus-empty distinction. 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 } // ValidSourceRefs reports whether refs are non-empty and valid for index. func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { if len(refs) == 0 { return false } for _, ref := range refs { if index.ValidateRef(ref) != nil { return false } } return true } // Less defines the canonical occurrence order. Invalid source references remain // comparable through SourceRefOrder's literal fallback so malformed candidates // are still safe to sort and diagnose. func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { left.SourceRefs = order.Canonicalize(left.SourceRefs) right.SourceRefs = order.Canonicalize(right.SourceRefs) return CanonicalLess(order, left, right) } // CanonicalLess defines the canonical occurrence order without modifying or // canonicalizing source references. Callers must provide canonical evidence. func CanonicalLess(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) if leftHasEvidence != rightHasEvidence { return leftHasEvidence } if leftHasEvidence && leftPosition != rightPosition { return leftPosition < rightPosition } if left.ItemID != right.ItemID { return left.ItemID < right.ItemID } if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey { return leftKey < rightKey } if leftName, rightName := DisplayValue(left.Name), DisplayValue(right.Name); leftName != rightName { return leftName < rightName } if left.Kind != right.Kind { return left.Kind < right.Kind } if less, decided := optionalStringLess(left.From, right.From); decided { return less } if less, decided := optionalStringLess(left.To, right.To); decided { return less } if less, decided := optionalQuantityLess(left.Quantity, right.Quantity); decided { return less } return sourceRefsLess(order, left.SourceRefs, right.SourceRefs) } // ExactEqual reports whether occurrences are exact duplicates after their display // fields and evidence have been canonicalized for the supplied source order. func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { left.SourceRefs = order.Canonicalize(left.SourceRefs) right.SourceRefs = order.Canonicalize(right.SourceRefs) return CanonicalExactEqual(left, right) } // CanonicalExactEqual reports whether canonical occurrences are exact // duplicates without modifying or canonicalizing their source references. func CanonicalExactEqual(left, right dnd.ItemOccurrence) bool { if left.ItemID != right.ItemID || DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind || DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) || (left.Quantity == nil) != (right.Quantity == nil) { return false } if left.Quantity != nil && *left.Quantity != *right.Quantity { return false } return SourceRefsEqual(left.SourceRefs, right.SourceRefs) } // ExactIdentity returns a collision-safe duplicate key after display and // evidence canonicalization. It is intended for callers that have already // decided the occurrence is eligible for duplicate handling. func ExactIdentity(order shared.SourceRefOrder, occurrence dnd.ItemOccurrence) string { occurrence.SourceRefs = order.Canonicalize(occurrence.SourceRefs) return CanonicalExactIdentity(occurrence) } // CanonicalExactIdentity returns a collision-safe duplicate key without // modifying or canonicalizing source references. Callers must provide // canonical evidence. func CanonicalExactIdentity(occurrence dnd.ItemOccurrence) string { var key strings.Builder writeKeyString(&key, occurrence.ItemID) writeKeyString(&key, DisplayValue(occurrence.Name)) writeKeyString(&key, string(occurrence.Kind)) writeKeyString(&key, DisplayValue(occurrence.From)) writeKeyString(&key, DisplayValue(occurrence.To)) if occurrence.Quantity == nil { key.WriteByte('0') } else { key.WriteByte('1') writeKeyInt(&key, *occurrence.Quantity) } writeKeySourceRefs(&key, occurrence.SourceRefs) return key.String() } func writeKeySourceRefs(builder *strings.Builder, refs []source.SourceRef) { if refs == nil { builder.WriteByte('0') return } builder.WriteByte('1') writeKeyInt(builder, len(refs)) for _, ref := range refs { writeKeyString(builder, ref.SourceID) writeKeyInt(builder, ref.StartUnitID) writeKeyInt(builder, ref.EndUnitID) } } func optionalStringLess(left, right string) (bool, bool) { leftPresent, rightPresent := HolderPresent(left), HolderPresent(right) if leftPresent != rightPresent { return !leftPresent, true } if !leftPresent { return false, false } if leftKey, rightKey := ComparisonKey(left), ComparisonKey(right); leftKey != rightKey { return leftKey < rightKey, true } if leftValue, rightValue := DisplayValue(left), DisplayValue(right); leftValue != rightValue { return leftValue < rightValue, true } return false, false } func optionalQuantityLess(left, right *int) (bool, bool) { if (left == nil) != (right == nil) { return left == nil, true } if left != nil && *left != *right { return *left < *right, true } return false, false } func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) bool { for index := 0; index < len(left) && index < len(right); index++ { if left[index] == right[index] { continue } return order.Less(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(';') }