diff --git a/docs/integrations/dnd-npc-artifacts.md b/docs/integrations/dnd-npc-artifacts.md new file mode 100644 index 0000000..89442c2 --- /dev/null +++ b/docs/integrations/dnd-npc-artifacts.md @@ -0,0 +1,57 @@ +# D&D NPC Artifact + +This document defines the durable D&D NPC-list artifact and its JSON codec. +The artifact type and codec are implemented, but no selectable production +pipeline currently produces this artifact. + +## Identity + +- Artifact kind: `dnd/npc-list` +- Durable schema ID: `notarius.dnd.npcs` +- Durable schema name: `notarius_dnd_npcs_v1` +- Durable schema version: `v1` +- Media type: `application/json` +- Identity policy: `dnd.npcs.identity.v1` + +The durable JSON Schema is owned by the D&D NPC codec. NPC IDs are derived from +the Unicode-normalized, case-folded canonical name using the identity policy. +The durable codec enforces the artifact shape and ID syntax; registry identity +validation remains a separate deterministic concern. + +## Output Shape + +The payload is one object with a required top-level `npcs` array: + +```json +{"npcs": []} +``` + +The array may be empty. Every object and nested object rejects unknown fields. + +## NPC Fields + +Each NPC contains exactly these required fields: + +- `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters; +- `name`: the canonical display name; +- `aliases`: an array of alternate display names, which may be empty; +- `description`: a concise description; +- `relationships`: an array of target/relationship objects, which may be empty; +- `source_refs`: at least one source reference supporting the NPC record. + +Each relationship contains required `target` and `relationship` strings. Each +source reference contains required `source_id`, `start_unit_id`, and +`end_unit_id`; unit IDs are positive integers. Source document identity, unit +existence, and range ordering are validated by the source-reference validator +when the artifact is used by a pipeline. + +## Codec Boundary + +`EncodeCandidate` and `DecodeCandidate` provide strict single-value JSON +serialization while preserving typed values that still need semantic +validation. `Encode` and `Decode` are the approved-artifact boundary and +require all durable structural fields, non-empty required strings, valid source +reference shapes, and the NPC ID pattern. + +Codec metadata contains only `npc_count`. Schema bytes and returned metadata +are independent values so callers cannot mutate codec-owned state. diff --git a/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json b/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json new file mode 100644 index 0000000..70188c2 --- /dev/null +++ b/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.npcs", + "type": "object", + "additionalProperties": false, + "required": ["npcs"], + "properties": { + "npcs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "aliases", + "description", + "relationships", + "source_refs" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^npc:sha256:[0-9a-f]{64}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "description": { + "type": "string", + "minLength": 1 + }, + "relationships": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["target", "relationship"], + "properties": { + "target": { + "type": "string", + "minLength": 1 + }, + "relationship": { + "type": "string", + "minLength": 1 + } + } + } + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/npcs/codec.go b/internal/modules/dnd/codec/npcs/codec.go new file mode 100644 index 0000000..8f296ce --- /dev/null +++ b/internal/modules/dnd/codec/npcs/codec.go @@ -0,0 +1,149 @@ +package npcs + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" +) + +const ( + SchemaID = "notarius.dnd.npcs" + SchemaName = "notarius_dnd_npcs_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_npcs.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.NPCList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npcs.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.NPCList) map[string]any { + return map[string]any{"npc_count": len(value.NPCs)} +} + +func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd npc list: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before semantic +// validators have approved a value. +func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) { + content, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode dnd npc list: %w", err) + } + return content, nil +} + +func (c *Codec) Decode(content []byte) (dnd.NPCList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.NPCList{}, err + } + if err := validate(value); err != nil { + return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. +func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var value dnd.NPCList + if err := decoder.Decode(&value); err != nil { + return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: multiple JSON values") + } + return value, nil +} + +func validate(value dnd.NPCList) error { + if value.NPCs == nil { + return fmt.Errorf("npcs must be present") + } + for index, npc := range value.NPCs { + prefix := fmt.Sprintf("npcs[%d]", index) + if !identity.IsValidID(npc.ID) { + return fmt.Errorf("%s.id must match npc ID pattern", prefix) + } + if strings.TrimSpace(npc.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if npc.Aliases == nil { + return fmt.Errorf("%s.aliases must be present", prefix) + } + for aliasIndex, alias := range npc.Aliases { + if strings.TrimSpace(alias) == "" { + return fmt.Errorf("%s.aliases[%d] must not be empty", prefix, aliasIndex) + } + } + if strings.TrimSpace(npc.Description) == "" { + return fmt.Errorf("%s.description must not be empty", prefix) + } + if npc.Relationships == nil { + return fmt.Errorf("%s.relationships must be present", prefix) + } + for relationshipIndex, relationship := range npc.Relationships { + relationshipPrefix := fmt.Sprintf("%s.relationships[%d]", prefix, relationshipIndex) + if strings.TrimSpace(relationship.Target) == "" { + return fmt.Errorf("%s.target must not be empty", relationshipPrefix) + } + if strings.TrimSpace(relationship.Relationship) == "" { + return fmt.Errorf("%s.relationship must not be empty", relationshipPrefix) + } + } + if len(npc.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must not be empty", prefix) + } + for refIndex, ref := range npc.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} diff --git a/internal/modules/dnd/codec/npcs/codec_test.go b/internal/modules/dnd/codec/npcs/codec_test.go new file mode 100644 index 0000000..319bba4 --- /dev/null +++ b/internal/modules/dnd/codec/npcs/codec_test.go @@ -0,0 +1,155 @@ +package npcs + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" +) + +func validList() dnd.NPCList { + return dnd.NPCList{NPCs: []dnd.NPC{{ + ID: identity.DeriveID("Mira Thorn"), + Name: "Mira Thorn", + Aliases: []string{"The Greencloak"}, + Description: "A guarded ranger who watches the northern road.", + Relationships: []dnd.NPCRelationship{{ + Target: "Captain Vale", Relationship: "reports to", + }}, + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, + }}} +} + +func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/dnd_npcs.v1.json") + if err != nil { + t.Fatalf("read durable fixture: %v", err) + } + codec := New() + value, err := codec.Decode(raw) + if err != nil { + t.Fatalf("Decode() error = %v, want nil", err) + } + want := validList() + if !reflect.DeepEqual(value, want) { + t.Fatalf("Decode() = %#v, want %#v", value, want) + } + encoded, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v, want nil", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + t.Fatalf("compact durable fixture: %v", err) + } + if !bytes.Equal(encoded, compact.Bytes()) { + t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes()) + } +} + +func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { + codec := New() + schema := codec.Schema() + if codec.Kind() != dnd.NPCListKind || codec.MediaType() != MediaType { + t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) + } + if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { + t.Fatalf("schema = %#v, want durable NPC schema", schema) + } + var document map[string]any + if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID { + t.Fatalf("durable schema document = %#v, %v", document, err) + } + registry := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { + t.Fatalf("RegisterArtifactCodec() error = %v", err) + } + spec, ok := registry.Spec(dnd.NPCListKind) + if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { + t.Fatalf("registered spec = %#v, %t", spec, ok) + } +} + +func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) { + codec := New() + tests := []struct { + name string + raw string + want string + }{ + {name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"}, + {name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"}, + {name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"}, + {name: "missing array", raw: `{}`, want: "npcs must be present"}, + {name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := codec.Decode([]byte(test.raw)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { + codec := New() + candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", Aliases: []string{}, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}} + content, err := codec.EncodeCandidate(candidate) + if err != nil || !json.Valid(content) { + t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), ".id must match npc ID pattern") { + t.Fatalf("Encode() error = %v, want strict validation error", err) + } +} + +func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) { + base := validList().NPCs[0] + tests := []struct { + name string + value dnd.NPCList + want string + }{ + {name: "nil aliases", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases must be present"}, + {name: "empty alias", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{" "}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases[0] must not be empty"}, + {name: "nil relationships", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, SourceRefs: base.SourceRefs}}}, want: "relationships must be present"}, + {name: "empty relationship target", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{{Relationship: "knows"}}, SourceRefs: base.SourceRefs}}}, want: "target must not be empty"}, + {name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { + codec := New() + first := codec.Schema() + first.JSONSchema[0] = '[' + second := codec.Schema() + if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' { + t.Fatal("Schema() returned shared bytes") + } + metadata := codec.Metadata(validList()) + metadata["other"] = true + if next := codec.Metadata(validList()); len(next) != 1 || next["npc_count"] != 1 { + t.Fatalf("Metadata() = %#v, want only npc_count", next) + } +} diff --git a/internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json b/internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json new file mode 100644 index 0000000..2ce9a15 --- /dev/null +++ b/internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json @@ -0,0 +1,12 @@ +{ + "npcs": [ + { + "id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7", + "name": "Mira Thorn", + "aliases": ["The Greencloak"], + "description": "A guarded ranger who watches the northern road.", + "relationships": [{"target": "Captain Vale", "relationship": "reports to"}], + "source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}] + } + ] +} diff --git a/internal/modules/dnd/npcs/identity/identity.go b/internal/modules/dnd/npcs/identity/identity.go new file mode 100644 index 0000000..dc13f9c --- /dev/null +++ b/internal/modules/dnd/npcs/identity/identity.go @@ -0,0 +1,230 @@ +// Package identity owns the stable identity policy for D&D non-player +// characters. +package identity + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +const ( + // Policy identifies the complete identity comparison and ID derivation + // policy. A future semantic change must use a new value. + Policy = "dnd.npcs.identity.v1" + // IdentityPolicy is an explicit alias for callers recording policy + // fingerprints. + IdentityPolicy = Policy +) + +const idPrefix = "npc:sha256:" + +// IssueCode identifies one deterministic registry identity problem. +type IssueCode string + +const ( + IssueEmptyCanonicalName IssueCode = "empty_canonical_name" + IssueEmptyAlias IssueCode = "empty_alias" + IssueInvalidID IssueCode = "invalid_id" + IssueIDMismatch IssueCode = "id_mismatch" + IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity" + IssueDuplicateID IssueCode = "duplicate_id" + IssueDuplicateAlias IssueCode = "duplicate_alias" + IssueOwnCanonicalAlias IssueCode = "alias_matches_canonical_name" + IssueAliasCanonicalCollision IssueCode = "alias_canonical_collision" + IssueAliasOwnershipCollision IssueCode = "alias_owned_by_multiple_records" +) + +// Issue is an inspectable identity validation problem. AliasIndex is -1 when +// the issue applies to an NPC as a whole rather than a particular alias. +type Issue struct { + Code IssueCode + RecordIndex int + AliasIndex int + Value string +} + +// NormalizeDisplay trims and collapses Unicode whitespace while retaining all +// other observed spelling and punctuation. +func NormalizeDisplay(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +// ComparisonKey returns the stable key used for NPC 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 returns the deterministic ID for a canonical NPC name. Empty +// identity keys intentionally produce an empty ID so shape validation can +// report the missing identity instead of manufacturing one. +func DeriveID(name string) string { + key := ComparisonKey(name) + if key == "" { + return "" + } + digest := sha256.Sum256([]byte(key)) + return idPrefix + hex.EncodeToString(digest[:]) +} + +// IDFor is a concise alias for DeriveID for callers that work with IDs as +// values rather than derivation operations. +func IDFor(name string) string { return DeriveID(name) } + +// IsValidID reports whether value has the exact durable NPC 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) } + +// ValidateRegistry checks all identity invariants without changing the input. +// It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList +// when the enclosing NPCList is more convenient at the call site. +func ValidateRegistry(npcs []dnd.NPC) []Issue { + type record struct { + canonical string + aliases []string + } + + records := make([]record, len(npcs)) + issues := make([]Issue, 0) + canonicalOwners := make(map[string][]int) + idOwners := make(map[string][]int) + aliasOwners := make(map[string][]int) + + for recordIndex, npc := range npcs { + canonical := ComparisonKey(npc.Name) + records[recordIndex].canonical = canonical + if canonical == "" { + issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.Name}) + } else { + canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex) + } + + if !IsValidID(npc.ID) { + issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) + } else if expected := DeriveID(npc.Name); npc.ID != expected { + issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) + } + if npc.ID != "" { + idOwners[npc.ID] = append(idOwners[npc.ID], recordIndex) + } + + seenAliases := make(map[string]int, len(npc.Aliases)) + for aliasIndex, alias := range npc.Aliases { + key := ComparisonKey(alias) + records[recordIndex].aliases = append(records[recordIndex].aliases, key) + if key == "" { + issues = append(issues, Issue{Code: IssueEmptyAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias}) + continue + } + if _, ok := seenAliases[key]; ok { + issues = append(issues, Issue{Code: IssueDuplicateAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias}) + } else { + seenAliases[key] = aliasIndex + } + if key == canonical { + issues = append(issues, Issue{Code: IssueOwnCanonicalAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias}) + } + aliasOwners[key] = append(aliasOwners[key], recordIndex) + } + } + + for recordIndex, record := range records { + if record.canonical != "" && len(canonicalOwners[record.canonical]) > 1 && canonicalOwners[record.canonical][0] != recordIndex { + issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, AliasIndex: -1, Value: npcs[recordIndex].Name}) + } + if id := npcs[recordIndex].ID; id != "" && len(idOwners[id]) > 1 && idOwners[id][0] != recordIndex { + issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, AliasIndex: -1, Value: id}) + } + } + + seenAliasKeys := make(map[string]struct{}) + for _, record := range records { + for _, alias := range record.aliases { + if alias == "" { + continue + } + if _, alreadyProcessed := seenAliasKeys[alias]; alreadyProcessed { + continue + } + seenAliasKeys[alias] = struct{}{} + owners := uniqueIndexes(aliasOwners[alias]) + if len(owners) > 1 { + for _, recordIndex := range owners { + issues = append(issues, Issue{Code: IssueAliasOwnershipCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias}) + } + } + for _, recordIndex := range owners { + for _, canonicalOwner := range canonicalOwners[alias] { + if canonicalOwner != recordIndex { + issues = append(issues, Issue{Code: IssueAliasCanonicalCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias}) + break + } + } + } + } + } + + return issues +} + +// ValidateList validates the identity members of list. +func ValidateList(list dnd.NPCList) []Issue { return ValidateRegistry(list.NPCs) } + +// Validate is a convenience alias for ValidateList. +func Validate(list dnd.NPCList) []Issue { return ValidateList(list) } + +func uniqueIndexes(values []int) []int { + seen := make(map[int]struct{}, len(values)) + unique := make([]int, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func aliasIndexFor(aliases []string, key string) int { + for index, alias := range aliases { + if alias == key { + return index + } + } + return -1 +} + +// Error makes an issue useful in simple callers while preserving its +// structured fields for aggregate diagnostics. +func (i Issue) Error() string { + return fmt.Sprintf("%s at record %d", i.Code, i.RecordIndex) +} diff --git a/internal/modules/dnd/npcs/identity/identity_test.go b/internal/modules/dnd/npcs/identity/identity_test.go new file mode 100644 index 0000000..270b125 --- /dev/null +++ b/internal/modules/dnd/npcs/identity/identity_test.go @@ -0,0 +1,114 @@ +package identity + +import ( + "strings" + "sync" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func TestComparisonKeyNormalizesSupportedEquivalences(t *testing.T) { + tests := []struct { + name string + left string + right string + }{ + {name: "case", left: "Captain Vale", right: "cAPtAiN vALE"}, + {name: "compatibility", left: "Ally", right: "Ally"}, + {name: "whitespace", left: " Mira\u2003Thorn ", right: "Mira Thorn"}, + {name: "apostrophe", left: "O’Rin", right: "o'Rin"}, + {name: "modifier apostrophe", left: "OʼRin", right: "o'Rin"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if ComparisonKey(test.left) != ComparisonKey(test.right) { + t.Fatalf("ComparisonKey(%q) = %q, ComparisonKey(%q) = %q", test.left, ComparisonKey(test.left), test.right, ComparisonKey(test.right)) + } + }) + } + if ComparisonKey("Mira Thorn") == ComparisonKey("Mira Thorne") { + t.Fatal("different names received the same comparison key") + } +} + +func TestNormalizeDisplayOnlyChangesWhitespace(t *testing.T) { + if got := NormalizeDisplay(" O’Rin\u2003Thorn "); got != "O’Rin Thorn" { + t.Fatalf("NormalizeDisplay() = %q", got) + } +} + +func TestDeriveIDAndIDSyntax(t *testing.T) { + got := DeriveID(" Mira\u2003Thorn ") + if len(got) != len("npc:sha256:")+64 || !strings.HasPrefix(got, "npc:sha256:") || !IsValidID(got) { + t.Fatalf("DeriveID() = %q, want exact NPC ID syntax", got) + } + if got != DeriveID("Mira Thorn") || got != IDFor("Mira Thorn") { + t.Fatal("DeriveID() is not deterministic across equivalent names") + } + if DeriveID(" \u2003 ") != "" { + t.Fatal("empty identity produced an ID") + } + for _, invalid := range []string{"", "npc:sha256:", "npc:sha256:ABC", "npc:sha256:" + strings.Repeat("0", 63), "npc:sha256:" + strings.Repeat("0", 65)} { + if IsValidID(invalid) { + t.Fatalf("IsValidID(%q) = true, want false", invalid) + } + } +} + +func TestIdentityFunctionsAreSafeForConcurrentUse(t *testing.T) { + const workers = 32 + var group sync.WaitGroup + for i := 0; i < workers; i++ { + group.Add(1) + go func() { + defer group.Done() + for j := 0; j < 100; j++ { + if !IsValidID(DeriveID("Mira Thorn")) { + t.Errorf("derived ID failed syntax check") + return + } + } + }() + } + group.Wait() +} + +func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) { + validID := DeriveID("Mira Thorn") + npcs := []dnd.NPC{ + {ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak", "the greencloak", "Mira Thorn"}}, + {ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak"}}, + {ID: DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"Mira Thorn"}}, + } + issues := ValidateRegistry(npcs) + want := map[IssueCode]bool{ + IssueDuplicateAlias: false, + IssueOwnCanonicalAlias: false, + IssueDuplicateCanonical: false, + IssueDuplicateID: false, + IssueAliasOwnershipCollision: false, + IssueAliasCanonicalCollision: false, + } + for _, issue := range issues { + if _, ok := want[issue.Code]; ok { + want[issue.Code] = true + } + } + for code, found := range want { + if !found { + t.Errorf("ValidateRegistry() did not report %s", code) + } + } +} + +func TestValidateRegistryReportsIDProblems(t *testing.T) { + issues := ValidateRegistry([]dnd.NPC{{ID: "bad", Name: "Mira Thorn"}, {ID: DeriveID("Mira Thorn"), Name: "Other Name"}}) + seen := map[IssueCode]bool{} + for _, issue := range issues { + seen[issue.Code] = true + } + if !seen[IssueInvalidID] || !seen[IssueIDMismatch] { + t.Fatalf("ValidateRegistry() issues = %#v, want invalid and mismatched ID issues", issues) + } +} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 0b06f63..882a5e2 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -8,6 +8,8 @@ import ( const SpellListKind contracts.ArtifactKind = "dnd/spell-list" +const NPCListKind contracts.ArtifactKind = "dnd/npc-list" + type SpellList struct { SpellCasts []SpellCast `json:"spell_casts"` } @@ -19,3 +21,21 @@ type SpellCast struct { NarrativeDescription string `json:"narrative_description"` SourceRefs []source.SourceRef `json:"source_refs"` } + +type NPCList struct { + NPCs []NPC `json:"npcs"` +} + +type NPC struct { + ID string `json:"id"` + Name string `json:"name"` + Aliases []string `json:"aliases"` + Description string `json:"description"` + Relationships []NPCRelationship `json:"relationships"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +type NPCRelationship struct { + Target string `json:"target"` + Relationship string `json:"relationship"` +}