diff --git a/internal/modules/dnd/locations/identity/identity.go b/internal/modules/dnd/locations/identity/identity.go new file mode 100644 index 0000000..e67e4e5 --- /dev/null +++ b/internal/modules/dnd/locations/identity/identity.go @@ -0,0 +1,183 @@ +// Package identity implements deterministic session-scoped location identity. +package identity + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +const ( + // IdentityPolicy identifies the durable location ID derivation policy. + IdentityPolicy = "dnd.locations.identity.v1" + // Policy is an alias for IdentityPolicy. + Policy = IdentityPolicy + + idPrefix = "location:sha256:" +) + +// IssueCode identifies one deterministic registry identity problem. +type IssueCode string + +const ( + IssueEmptyCanonicalName IssueCode = "empty_canonical_name" + IssueMissingEvidence IssueCode = "missing_evidence" + IssueInvalidID IssueCode = "invalid_id" + IssueIDMismatch IssueCode = "id_mismatch" + IssueDuplicateID IssueCode = "duplicate_id" +) + +// Issue is an inspectable identity validation problem. +type Issue struct { + Code IssueCode + RecordIndex int + Value string +} + +func (i Issue) Error() string { return string(i.Code) } + +// NormalizeDisplay returns the durable display form without changing spelling +// or punctuation. +func NormalizeDisplay(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +// ComparisonKey returns the stable key used for location identity comparisons. +func ComparisonKey(value string) string { + value = norm.NFKC.String(value) + value = strings.Map(func(r rune) rune { + switch r { + case '\u2018', '\u2019', '\u02bc': + return '\'' + default: + return r + } + }, value) + value = strings.Join(strings.Fields(value), " ") + return cases.Fold().String(value) +} + +// DeriveID derives a location ID from name and the earliest canonical evidence +// reference. It returns an empty string when either identity component is not +// available, leaving validation to report the problem instead of manufacturing +// an ID. +func DeriveID(name string, refs []source.SourceRef) string { + comparisonName := ComparisonKey(name) + anchor, ok := earliestReference(refs) + if comparisonName == "" || !ok { + return "" + } + + input, err := json.Marshal([]any{ + IdentityPolicy, + comparisonName, + anchor.SourceID, + anchor.StartUnitID, + anchor.EndUnitID, + }) + if err != nil { + return "" + } + digest := sha256.Sum256(input) + return idPrefix + hex.EncodeToString(digest[:]) +} + +// IDFor is an alias for DeriveID. +func IDFor(name string, refs []source.SourceRef) string { return DeriveID(name, refs) } + +// IsValidID reports whether value has the exact durable location ID syntax. +func IsValidID(value string) bool { + if len(value) != len(idPrefix)+sha256.Size*2 || !strings.HasPrefix(value, idPrefix) { + return false + } + for _, r := range value[len(idPrefix):] { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return false + } + } + return true +} + +// ValidID is an alias for IsValidID. +func ValidID(value string) bool { return IsValidID(value) } + +// ValidateList validates the identity members of list. +func ValidateList(list dnd.LocationList) []Issue { return ValidateRegistry(list.Locations) } + +// ValidateRegistry validates location IDs without modifying records or their +// source references. Equal comparison names are allowed because their evidence +// anchors are part of the identity policy. +func ValidateRegistry(locations []dnd.Location) []Issue { + issues := make([]Issue, 0) + idOwners := make(map[string][]int) + + for recordIndex, location := range locations { + if ComparisonKey(location.Name) == "" { + issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, Value: location.Name}) + } + if _, ok := earliestReference(location.SourceRefs); !ok { + issues = append(issues, Issue{Code: IssueMissingEvidence, RecordIndex: recordIndex}) + } + + if !IsValidID(location.ID) { + issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, Value: location.ID}) + } else if expected := DeriveID(location.Name, location.SourceRefs); location.ID != expected { + issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, Value: location.ID}) + } + if location.ID != "" { + idOwners[location.ID] = append(idOwners[location.ID], recordIndex) + } + } + + for recordIndex, location := range locations { + if location.ID != "" && len(idOwners[location.ID]) > 1 && idOwners[location.ID][0] != recordIndex { + issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, Value: location.ID}) + } + } + + return issues +} + +func earliestReference(refs []source.SourceRef) (source.SourceRef, bool) { + canonical := canonicalReferences(refs) + if len(canonical) == 0 { + return source.SourceRef{}, false + } + return canonical[0], true +} + +func canonicalReferences(refs []source.SourceRef) []source.SourceRef { + canonical := make([]source.SourceRef, 0, len(refs)) + for _, ref := range refs { + if validIdentityReference(ref) { + canonical = append(canonical, ref) + } + } + sort.Slice(canonical, func(left, right int) bool { + if canonical[left].SourceID != canonical[right].SourceID { + return canonical[left].SourceID < canonical[right].SourceID + } + if canonical[left].StartUnitID != canonical[right].StartUnitID { + return canonical[left].StartUnitID < canonical[right].StartUnitID + } + return canonical[left].EndUnitID < canonical[right].EndUnitID + }) + unique := canonical[:0] + for _, ref := range canonical { + if len(unique) == 0 || unique[len(unique)-1] != ref { + unique = append(unique, ref) + } + } + return unique +} + +func validIdentityReference(ref source.SourceRef) bool { + return strings.TrimSpace(ref.SourceID) == ref.SourceID && ref.SourceID != "" && ref.StartUnitID > 0 && ref.EndUnitID > 0 +} diff --git a/internal/modules/dnd/locations/identity/identity_test.go b/internal/modules/dnd/locations/identity/identity_test.go new file mode 100644 index 0000000..197cce9 --- /dev/null +++ b/internal/modules/dnd/locations/identity/identity_test.go @@ -0,0 +1,146 @@ +package identity + +import ( + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func TestNormalizeDisplayOnlyChangesWhitespace(t *testing.T) { + if got, want := NormalizeDisplay(" The\u2003Old\nTavern "), "The Old Tavern"; got != want { + t.Fatalf("NormalizeDisplay() = %q, want %q", got, want) + } +} + +func TestComparisonKeyNormalizesUnicodeWhitespaceAndApostrophes(t *testing.T) { + for _, pair := range [][2]string{ + {" Caf\u00e9\u2003d\u2019Or ", "cafe\u0301 d'Or"}, + {"\uff34\uff48\uff45\u00a0\uff34\uff41\uff56\uff45\uff52\uff4e", "the tavern"}, + } { + if left, right := ComparisonKey(pair[0]), ComparisonKey(pair[1]); left != right { + t.Fatalf("ComparisonKey(%q) = %q, want value equal to %q", pair[0], left, pair[1]) + } + } +} + +func TestDeriveIDUsesDocumentedCompactJSONInput(t *testing.T) { + refs := []source.SourceRef{ + {SourceID: "session-alpha", StartUnitID: 9, EndUnitID: 9}, + {SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 9}, + {SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 9}, + } + const want = "location:sha256:379bd996e754e143b47e6b613a95166e58c111c7451750df7066ca816bb1866c" + + if got := DeriveID(" The\u2003Tavern ", refs); got != want { + t.Fatalf("DeriveID() = %q, want %q", got, want) + } + if got := IDFor("The Tavern", refs); got != want { + t.Fatalf("IDFor() = %q, want %q", got, want) + } +} + +func TestDeriveIDUsesEarliestCanonicalEvidenceWithoutMutatingInput(t *testing.T) { + refs := []source.SourceRef{ + {SourceID: "zeta", StartUnitID: 1, EndUnitID: 1}, + {SourceID: "alpha", StartUnitID: 8, EndUnitID: 8}, + {SourceID: "alpha", StartUnitID: 2, EndUnitID: 3}, + } + wantRefs := append([]source.SourceRef(nil), refs...) + + first := DeriveID("The Tavern", refs) + second := DeriveID("The Tavern", []source.SourceRef{refs[2], refs[0], refs[1]}) + if first != second { + t.Fatalf("DeriveID() changed when evidence order changed: %q != %q", first, second) + } + if !reflect.DeepEqual(refs, wantRefs) { + t.Fatalf("DeriveID() mutated refs: got %#v, want %#v", refs, wantRefs) + } +} + +func TestDeriveIDDistinguishesSameNameAtDifferentEvidenceAnchors(t *testing.T) { + first := DeriveID("The Tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 3, EndUnitID: 3}}) + second := DeriveID("the\u00a0tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 18, EndUnitID: 18}}) + if first == "" || second == "" || first == second { + t.Fatalf("same-name locations have IDs %q and %q, want distinct nonempty IDs", first, second) + } +} + +func TestDeriveIDRejectsMissingIdentityComponents(t *testing.T) { + for _, tt := range []struct { + name string + nameValue string + refs []source.SourceRef + }{ + {name: "blank name", nameValue: " \u2003 ", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, + {name: "missing refs", nameValue: "The Tavern"}, + {name: "malformed ref", nameValue: "The Tavern", refs: []source.SourceRef{{SourceID: " ", StartUnitID: 1, EndUnitID: 1}}}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := DeriveID(tt.nameValue, tt.refs); got != "" { + t.Fatalf("DeriveID() = %q, want empty ID", got) + } + }) + } +} + +func TestIsValidID(t *testing.T) { + valid := DeriveID("The Tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}) + if !IsValidID(valid) || !ValidID(valid) { + t.Fatalf("derived ID %q is not valid", valid) + } + for _, value := range []string{"", "location:sha256:", "location:sha256:" + strings.Repeat("A", 64), "location:sha256:" + strings.Repeat("0", 63)} { + if IsValidID(value) { + t.Fatalf("IsValidID(%q) = true, want false", value) + } + } +} + +func TestValidateRegistryAllowsSameComparisonNameWithDifferentAnchors(t *testing.T) { + firstRefs := []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}} + secondRefs := []source.SourceRef{{SourceID: "session", StartUnitID: 8, EndUnitID: 8}} + locations := []dnd.Location{ + {ID: DeriveID("The Tavern", firstRefs), Name: "The Tavern", SourceRefs: firstRefs}, + {ID: DeriveID("the\u00a0tavern", secondRefs), Name: "the\u00a0tavern", SourceRefs: secondRefs}, + } + if issues := ValidateRegistry(locations); len(issues) != 0 { + t.Fatalf("ValidateRegistry() = %#v, want no issues", issues) + } +} + +func TestValidateRegistryReportsIdentityProblemsDeterministicallyWithoutMutation(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}} + validID := DeriveID("The Tavern", refs) + locations := []dnd.Location{ + {ID: validID, Name: "The Tavern", SourceRefs: refs}, + {ID: validID, Name: "Elsewhere", SourceRefs: refs}, + {ID: "bad", Name: "", SourceRefs: nil}, + } + wantLocations := cloneLocations(locations) + + issues := ValidateList(dnd.LocationList{Locations: locations}) + want := []Issue{ + {Code: IssueIDMismatch, RecordIndex: 1, Value: validID}, + {Code: IssueEmptyCanonicalName, RecordIndex: 2, Value: ""}, + {Code: IssueMissingEvidence, RecordIndex: 2}, + {Code: IssueInvalidID, RecordIndex: 2, Value: "bad"}, + {Code: IssueDuplicateID, RecordIndex: 1, Value: validID}, + } + if !reflect.DeepEqual(issues, want) { + t.Fatalf("ValidateList() = %#v, want %#v", issues, want) + } + if !reflect.DeepEqual(locations, wantLocations) { + t.Fatalf("ValidateList() mutated locations: got %#v, want %#v", locations, wantLocations) + } +} + +func cloneLocations(input []dnd.Location) []dnd.Location { + output := make([]dnd.Location, len(input)) + copy(output, input) + for index := range output { + output[index].SourceRefs = append([]source.SourceRef(nil), input[index].SourceRefs...) + } + return output +} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 945425b..6d061c1 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -20,6 +20,10 @@ const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list" const EnemyEventListKind contracts.ArtifactKind = "dnd/enemy-event-list" +const LocationListKind contracts.ArtifactKind = "dnd/location-list" + +const LocationOccurrenceListKind contracts.ArtifactKind = "dnd/location-occurrence-list" + type SpellList struct { SpellCasts []SpellCast `json:"spell_casts"` } @@ -144,3 +148,33 @@ type EnemyEvent struct { Kind EnemyEventKind `json:"kind"` SourceRefs []source.SourceRef `json:"source_refs"` } + +type LocationList struct { + Locations []Location `json:"locations"` +} + +type Location struct { + ID string `json:"id"` + Name string `json:"name"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +type LocationOccurrenceKind string + +const ( + LocationOccurrenceKindVisited LocationOccurrenceKind = "visited" + LocationOccurrenceKindPlanned LocationOccurrenceKind = "planned" + LocationOccurrenceKindRecalled LocationOccurrenceKind = "recalled" + LocationOccurrenceKindMentioned LocationOccurrenceKind = "mentioned" +) + +type LocationOccurrenceList struct { + Occurrences []LocationOccurrence `json:"occurrences"` +} + +type LocationOccurrence struct { + LocationID string `json:"location_id"` + Name string `json:"name"` + Kind LocationOccurrenceKind `json:"kind"` + SourceRefs []source.SourceRef `json:"source_refs"` +}