From f320c2fcee13ae7732cde88896421860fd265bb6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 25 Jul 2026 21:41:17 +0000 Subject: [PATCH] Add D&D item event artifact contract --- .../assets/schemas/dnd_item_events.v1.json | 45 ++++ .../modules/dnd/codec/itemevents/codec.go | 146 ++++++++++++ .../dnd/codec/itemevents/codec_test.go | 146 ++++++++++++ internal/modules/dnd/itemevents/itemevents.go | 209 ++++++++++++++++++ .../modules/dnd/itemevents/itemevents_test.go | 169 ++++++++++++++ internal/modules/dnd/types.go | 25 +++ 6 files changed, 740 insertions(+) create mode 100644 internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json create mode 100644 internal/modules/dnd/codec/itemevents/codec.go create mode 100644 internal/modules/dnd/codec/itemevents/codec_test.go create mode 100644 internal/modules/dnd/itemevents/itemevents.go create mode 100644 internal/modules/dnd/itemevents/itemevents_test.go diff --git a/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json b/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json new file mode 100644 index 0000000..2931c8e --- /dev/null +++ b/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.item_events", + "type": "object", + "additionalProperties": false, + "required": ["events"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "source_refs"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]}, + "quantity": {"type": "integer", "minimum": 1}, + "from": {"type": "string", "minLength": 1}, + "to": {"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} + } + } + } + }, + "allOf": [ + {"if": {"properties": {"kind": {"const": "discovered"}}, "required": ["kind"]}, "then": {"not": {"anyOf": [{"required": ["from"]}, {"required": ["to"]}]}}}, + {"if": {"properties": {"kind": {"const": "acquired"}}, "required": ["kind"]}, "then": {"required": ["to"], "not": {"required": ["from"]}}}, + {"if": {"properties": {"kind": {"const": "lost"}}, "required": ["kind"]}, "then": {"required": ["from"], "not": {"required": ["to"]}}}, + {"if": {"properties": {"kind": {"const": "consumed"}}, "required": ["kind"]}, "then": {"required": ["from"], "not": {"required": ["to"]}}}, + {"if": {"properties": {"kind": {"const": "transferred"}}, "required": ["kind"]}, "then": {"required": ["from", "to"]}} + ] + } + } + } +} diff --git a/internal/modules/dnd/codec/itemevents/codec.go b/internal/modules/dnd/codec/itemevents/codec.go new file mode 100644 index 0000000..0779359 --- /dev/null +++ b/internal/modules/dnd/codec/itemevents/codec.go @@ -0,0 +1,146 @@ +// Package itemevents encodes durable D&D item-event artifacts. +package itemevents + +import ( + "embed" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents" +) + +const ( + SchemaID = "notarius.dnd.item_events" + SchemaName = "notarius_dnd_item_events_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_item_events.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.ItemEventList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemEventListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_events.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.ItemEventList) map[string]any { + return map[string]any{"event_count": len(value.Events)} +} + +func (c *Codec) Encode(value dnd.ItemEventList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd item event 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.ItemEventList) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd item event list", cloneList(value)) +} + +func (c *Codec) Decode(content []byte) (dnd.ItemEventList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.ItemEventList{}, err + } + if err := validate(value); err != nil { + return dnd.ItemEventList{}, fmt.Errorf("decode dnd item event 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.ItemEventList, error) { + value, err := candidatejson.DecodeCandidate[dnd.ItemEventList]("dnd item event list", content) + if err != nil { + return dnd.ItemEventList{}, err + } + return cloneList(value), nil +} + +func validate(value dnd.ItemEventList) error { + if value.Events == nil { + return fmt.Errorf("events must be present") + } + for index, event := range value.Events { + prefix := fmt.Sprintf("events[%d]", index) + if strings.TrimSpace(event.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if !itemevents.SupportedKind(event.Kind) { + return fmt.Errorf("%s.kind must be supported", prefix) + } + if event.From != "" && strings.TrimSpace(event.From) == "" { + return fmt.Errorf("%s.from must not be empty when present", prefix) + } + if event.To != "" && strings.TrimSpace(event.To) == "" { + return fmt.Errorf("%s.to must not be empty when present", prefix) + } + if !itemevents.ValidHolderCombination(event.Kind, event.From, event.To) { + return fmt.Errorf("%s holders are incompatible with %q", prefix, event.Kind) + } + if event.Quantity != nil && *event.Quantity < 1 { + return fmt.Errorf("%s.quantity must be positive when present", prefix) + } + if len(event.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for refIndex, ref := range event.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 +} + +func cloneList(value dnd.ItemEventList) dnd.ItemEventList { + if value.Events == nil { + return dnd.ItemEventList{} + } + cloned := dnd.ItemEventList{Events: make([]dnd.ItemEvent, len(value.Events))} + for index, event := range value.Events { + cloned.Events[index] = event + if event.Quantity != nil { + quantity := *event.Quantity + cloned.Events[index].Quantity = &quantity + } + if event.SourceRefs != nil { + cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...) + } + } + return cloned +} diff --git a/internal/modules/dnd/codec/itemevents/codec_test.go b/internal/modules/dnd/codec/itemevents/codec_test.go new file mode 100644 index 0000000..db69582 --- /dev/null +++ b/internal/modules/dnd/codec/itemevents/codec_test.go @@ -0,0 +1,146 @@ +package itemevents + +import ( + "bytes" + "encoding/json" + "errors" + "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" +) + +func validList() dnd.ItemEventList { + quantity := 12 + return dnd.ItemEventList{Events: []dnd.ItemEvent{ + {Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}, + {Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)}, + {Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)}, + {Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)}, + {Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, + }} +} + +func refs(start, end int) []source.SourceRef { + return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} +} + +func TestCodecRoundTripAndIdentities(t *testing.T) { + codec := New() + value := validList() + content, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + decoded, err := codec.Decode(content) + if err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value) + } + + schema := codec.Schema() + if codec.Kind() != dnd.ItemEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { + t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema) + } + registry := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { + t.Fatal(err) + } + spec, ok := registry.Spec(dnd.ItemEventListKind) + if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { + t.Fatalf("registered spec = %#v, %t", spec, ok) + } + if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCList{}); err == nil { + t.Fatal("Encode() error = nil, want exact type rejection") + } else { + var typeErr *pipeline.ArtifactCodecTypeError + if !errors.As(err, &typeErr) { + t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err) + } + } +} + +func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { + codec := New() + empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}} + if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` { + t.Fatalf("Encode() = %s, %v", content, err) + } + zero := 0 + candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{ + Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party", + SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}}, + }}} + content, err := codec.EncodeCandidate(candidate) + if err != nil || !json.Valid(content) { + t.Fatalf("EncodeCandidate() = %s, %v", 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.Decode(content); err == nil { + t.Fatal("Decode() error = nil, want semantic candidate rejection") + } +} + +func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) { + validJSON := `{"events":[{"name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` + tests := []struct { + name, raw, want string + }{ + {"malformed", `{`, "decode dnd item event list"}, + {"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"}, + {"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"}, + {"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, + {"trailing", `{"events":[]} {}`, "multiple JSON values"}, + {"missing list", `{}`, "events must be present"}, + {"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"}, + {"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"}, + {"party transfer", strings.Replace(validJSON, `"kind":"acquired","to":"party"`, `"kind":"transferred","from":"party","to":"Borin"`, 1), "holders are incompatible"}, + {"self transfer", strings.Replace(validJSON, `"kind":"acquired","to":"party"`, `"kind":"transferred","from":"Aria","to":"aria"`, 1), "holders are incompatible"}, + {"missing source refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "source_refs must contain"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) { + codec := New() + value := validList() + content, err := codec.EncodeCandidate(value) + if err != nil { + t.Fatal(err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + if decoded.Events[1].Quantity == value.Events[1].Quantity || &decoded.Events[1].SourceRefs[0] == &value.Events[1].SourceRefs[0] { + t.Fatal("DecodeCandidate() retained caller-owned event fields") + } + *decoded.Events[1].Quantity = 99 + decoded.Events[1].SourceRefs[0].SourceID = "changed" + if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" { + t.Fatal("decoded item event aliases input") + } + + first := codec.Schema() + first.JSONSchema[0] = '[' + if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' { + t.Fatal("Schema() returned shared bytes") + } + metadata := codec.Metadata(value) + metadata["payload"] = bytes.Repeat([]byte("x"), 10) + if next := codec.Metadata(value); len(next) != 1 || next["event_count"] != len(value.Events) { + t.Fatalf("Metadata() = %#v", next) + } +} diff --git a/internal/modules/dnd/itemevents/itemevents.go b/internal/modules/dnd/itemevents/itemevents.go new file mode 100644 index 0000000..71961f3 --- /dev/null +++ b/internal/modules/dnd/itemevents/itemevents.go @@ -0,0 +1,209 @@ +// Package itemevents owns canonical ordering and durable domain rules for D&D +// item-event artifacts. +package itemevents + +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/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" +) + +const partyHolder = "party" + +// SupportedKind reports whether kind is one of the durable item-event kinds. +func SupportedKind(kind dnd.ItemEventKind) bool { + switch kind { + case dnd.ItemEventKindDiscovered, + dnd.ItemEventKindAcquired, + dnd.ItemEventKindLost, + dnd.ItemEventKindConsumed, + dnd.ItemEventKindTransferred: + 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 identity.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.ItemEventKind, from, to string) bool { + hasFrom := HolderPresent(from) + hasTo := HolderPresent(to) + + switch kind { + case dnd.ItemEventKindDiscovered: + return !hasFrom && !hasTo + case dnd.ItemEventKindAcquired: + return !hasFrom && hasTo + case dnd.ItemEventKindLost, dnd.ItemEventKindConsumed: + return hasFrom && !hasTo + case dnd.ItemEventKindTransferred: + 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 event 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.ItemEvent) 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 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, order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs)) +} + +// ExactEqual reports whether events 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.ItemEvent) bool { + if 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(order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs)) +} + +// ExactIdentity returns a collision-safe duplicate key after display and +// evidence canonicalization. It is intended for callers that have already +// decided the event is eligible for duplicate handling. +func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemEvent) string { + var key strings.Builder + writeKeyString(&key, DisplayValue(event.Name)) + writeKeyString(&key, string(event.Kind)) + writeKeyString(&key, DisplayValue(event.From)) + writeKeyString(&key, DisplayValue(event.To)) + if event.Quantity == nil { + key.WriteByte('0') + } else { + key.WriteByte('1') + writeKeyInt(&key, *event.Quantity) + } + for _, ref := range order.Canonicalize(event.SourceRefs) { + writeKeyString(&key, ref.SourceID) + writeKeyInt(&key, ref.StartUnitID) + writeKeyInt(&key, ref.EndUnitID) + } + return key.String() +} + +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(';') +} diff --git a/internal/modules/dnd/itemevents/itemevents_test.go b/internal/modules/dnd/itemevents/itemevents_test.go new file mode 100644 index 0000000..62bbfe8 --- /dev/null +++ b/internal/modules/dnd/itemevents/itemevents_test.go @@ -0,0 +1,169 @@ +package itemevents + +import ( + "sort" + "testing" + + "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" +) + +func TestValidHolderCombination(t *testing.T) { + valid := []struct { + kind dnd.ItemEventKind + from, to string + }{ + {dnd.ItemEventKindDiscovered, "", ""}, + {dnd.ItemEventKindAcquired, "", "party"}, + {dnd.ItemEventKindLost, "party", ""}, + {dnd.ItemEventKindConsumed, "party", ""}, + {dnd.ItemEventKindTransferred, "Aria", "Borin"}, + } + for _, test := range valid { + if !ValidHolderCombination(test.kind, test.from, test.to) { + t.Fatalf("ValidHolderCombination(%q, %q, %q) = false", test.kind, test.from, test.to) + } + } + + invalid := []struct { + kind dnd.ItemEventKind + from, to string + }{ + {dnd.ItemEventKindDiscovered, "Aria", ""}, + {dnd.ItemEventKindAcquired, "", ""}, + {dnd.ItemEventKindLost, "", ""}, + {dnd.ItemEventKindConsumed, "", "Borin"}, + {dnd.ItemEventKindTransferred, "party", "Borin"}, + {dnd.ItemEventKindTransferred, "Aria", "Party"}, + {dnd.ItemEventKindTransferred, "Aria", "aria"}, + {dnd.ItemEventKindTransferred, "Åria", "Åria"}, + {"unsupported", "", ""}, + } + for _, test := range invalid { + if ValidHolderCombination(test.kind, test.from, test.to) { + t.Fatalf("ValidHolderCombination(%q, %q, %q) = true", test.kind, test.from, test.to) + } + } +} + +func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) { + order := testOrder() + ref := func(start, end int) []source.SourceRef { + return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} + } + quantity := func(value int) *int { return &value } + base := dnd.ItemEvent{Name: "Amulet", Kind: dnd.ItemEventKindAcquired, To: "Borin", SourceRefs: ref(20, 20)} + tests := []struct { + name string + left, right dnd.ItemEvent + }{ + {"earlier evidence", withRefs(base, ref(10, 10)), base}, + {"valid evidence before malformed", base, withRefs(base, ref(999, 999))}, + {"normalized name", withName(base, "Amulet"), withName(base, "Blade")}, + {"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")}, + {"kind", withKind(base, dnd.ItemEventKindAcquired), withKind(base, dnd.ItemEventKindLost)}, + {"from presence", withFrom(base, ""), withFrom(base, "Aria")}, + {"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")}, + {"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")}, + {"to presence", withoutTo(base), withTo(base, "Borin")}, + {"to normalized value", withTo(base, "Aria"), withTo(base, "Borin")}, + {"to exact value", withTo(base, "Aria"), withTo(base, "aria")}, + {"quantity presence", withQuantity(base, nil), withQuantity(base, quantity(1))}, + {"quantity value", withQuantity(base, quantity(1)), withQuantity(base, quantity(2))}, + {"canonical source reference sequence", withRefs(base, ref(20, 20)), withRefs(base, ref(30, 30))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if !Less(order, test.left, test.right) || Less(order, test.right, test.left) { + t.Fatalf("Less() did not order %#v before %#v", test.left, test.right) + } + }) + } +} + +func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) { + order := testOrder() + first := dnd.ItemEvent{ + Name: " Silver Coin ", Kind: dnd.ItemEventKindAcquired, To: " party ", + SourceRefs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 30, EndUnitID: 30}, + {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, + {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, + }, + } + second := dnd.ItemEvent{ + Name: "Silver Coin", Kind: dnd.ItemEventKindAcquired, To: "party", + SourceRefs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, + {SourceID: "session", StartUnitID: 30, EndUnitID: 30}, + }, + } + if !ExactEqual(order, first, second) { + t.Fatal("ExactEqual() = false, want canonical duplicate") + } + if Less(order, first, second) || Less(order, second, first) { + t.Fatal("Less() distinguished canonically equal records") + } + if ExactIdentity(order, first) != ExactIdentity(order, second) { + t.Fatal("ExactIdentity() differs for canonical duplicates") + } + + quantity := 1 + differentQuantity := second + differentQuantity.Quantity = &quantity + differentEvidence := second + differentEvidence.SourceRefs = append([]source.SourceRef(nil), second.SourceRefs...) + differentEvidence.SourceRefs[1].EndUnitID = 20 + if ExactEqual(order, second, differentQuantity) || ExactEqual(order, second, differentEvidence) { + t.Fatal("ExactEqual() collapsed distinct optional field or evidence values") + } +} + +func TestSourceReferenceHelpers(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 20}} + if !SourceRefsEqual(refs, append([]source.SourceRef(nil), refs...)) || SourceRefsEqual(nil, []source.SourceRef{}) { + t.Fatal("SourceRefsEqual() did not preserve source-reference representation") + } + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}} + if !ValidSourceRefs(source.NewDocumentIndex(doc), refs) { + t.Fatal("ValidSourceRefs() = false, want valid reference") + } + if ValidSourceRefs(source.NewDocumentIndex(doc), []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 20}}) { + t.Fatal("ValidSourceRefs() = true, want invalid source identifier rejection") + } +} + +func TestLessSortsMalformedReferencesDeterministically(t *testing.T) { + order := testOrder() + events := []dnd.ItemEvent{ + {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}}, + {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}}, + {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + } + sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) }) + if events[0].SourceRefs[0].StartUnitID != 10 || events[1].SourceRefs[0].SourceID != "other" || events[2].SourceRefs[0].StartUnitID != 999 { + t.Fatalf("canonical sort = %#v", events) + } +} + +func testOrder() shared.SourceRefOrder { + return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}) +} + +func withName(event dnd.ItemEvent, value string) dnd.ItemEvent { event.Name = value; return event } +func withKind(event dnd.ItemEvent, value dnd.ItemEventKind) dnd.ItemEvent { + event.Kind = value + return event +} +func withFrom(event dnd.ItemEvent, value string) dnd.ItemEvent { event.From = value; return event } +func withTo(event dnd.ItemEvent, value string) dnd.ItemEvent { event.To = value; return event } +func withoutTo(event dnd.ItemEvent) dnd.ItemEvent { event.To = ""; return event } +func withQuantity(event dnd.ItemEvent, value *int) dnd.ItemEvent { + event.Quantity = value + return event +} +func withRefs(event dnd.ItemEvent, value []source.SourceRef) dnd.ItemEvent { + event.SourceRefs = value + return event +} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index f03238e..5e4423a 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -16,6 +16,8 @@ const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list" const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list" +const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list" + type SpellList struct { SpellCasts []SpellCast `json:"spell_casts"` } @@ -97,3 +99,26 @@ type SceneDescription struct { Title string `json:"title"` Summary string `json:"summary"` } + +type ItemEventKind string + +const ( + ItemEventKindDiscovered ItemEventKind = "discovered" + ItemEventKindAcquired ItemEventKind = "acquired" + ItemEventKindLost ItemEventKind = "lost" + ItemEventKindConsumed ItemEventKind = "consumed" + ItemEventKindTransferred ItemEventKind = "transferred" +) + +type ItemEventList struct { + Events []ItemEvent `json:"events"` +} + +type ItemEvent struct { + Name string `json:"name"` + Kind ItemEventKind `json:"kind"` + Quantity *int `json:"quantity,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + SourceRefs []source.SourceRef `json:"source_refs"` +}