Adopt registry-backed NPC occurrence artifacts

This commit is contained in:
2026-08-05 18:55:58 +00:00
parent 3f4a1f2647
commit 5e2ccffc0f
42 changed files with 676 additions and 528 deletions

View File

@@ -25,7 +25,7 @@ type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
@@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -87,9 +87,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return rejection([]string{"NPC registry reference is required"}), nil
}
issues := make([]string, 0)
for index, interaction := range req.Value.Interactions {
if _, ok := npcRegistry.Lookup(interaction.Name); !ok {
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name)))
for index, occurrence := range req.Value.Occurrences {
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
if !ok {
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
continue
}
if occurrence.Name != canonical.Name {
issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name)))
}
}
if len(issues) == 0 {
@@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec {
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -16,19 +16,25 @@ import (
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
)
func TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) {
func TestValidatorRecognizesExactRegistryPairsAndRejectsUnknownIDs(t *testing.T) {
references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references)
value := validList(" mira thorn ")
value := validList("Mira Thorn")
result, err := validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("recognized result = %#v, %v", result, err)
}
value.Interactions[0].Name = "Unknown NPC"
value.Occurrences[0].NPCID = "npc:unknown"
result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") {
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].npc_id") {
t.Fatalf("unknown result = %#v, %v", result, err)
}
value = validList("Mira Thorn")
value.Occurrences[0].Name = "Hooded Guard"
result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match npc_id") {
t.Fatalf("mismatched result = %#v, %v", result, err)
}
}
func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) {
@@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t
}
empty := registryReferences(t)
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}))
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}))
if err != nil || !result.Approved {
t.Fatalf("empty registry result = %#v, %v", result, err)
}
@@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references)
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}
result, err := validator.Validate(context.Background(), request(references, malformed))
if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err)
@@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
return validator
}
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value}
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value}
}
func validList(name string) dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: name, Kind: dnd.NPCInteractionKindDialogue,
func validList(name string) dnd.NPCOccurrenceList {
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}