Implement operation-time D&D NPC artifact handoff

This commit is contained in:
2026-07-21 22:12:01 +00:00
parent c437682407
commit 9184072839
12 changed files with 606 additions and 27 deletions

View File

@@ -45,7 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
npcRegistry *npcregistry.Registry
npcResolver *npcregistry.Resolver
}
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
@@ -56,11 +56,11 @@ func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
if len(references) == 1 {
referenceSet = references[0]
}
npcRegistry, err := npcregistry.Resolve(referenceSet)
npcResolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, normalizerErrorf("prepare NPC registry: %w", err)
}
return &Normalizer{npcRegistry: npcRegistry}, nil
return &Normalizer{npcResolver: npcResolver}, nil
}
func (n *Normalizer) Key() string { return Key }
@@ -75,9 +75,10 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
"normalization_policy": normalizationPolicy,
"identity_policy": identity.Policy,
}
if n.npcRegistry.Bound() {
metadata["npc_registry_digest"] = n.npcRegistry.Digest()
metadata["npc_count"] = n.npcRegistry.Count()
seeded := n.npcResolver.Seeded()
if seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
return metadata
}
@@ -90,8 +91,9 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "identity_policy", Value: identity.Policy},
}
if n.npcRegistry.Bound() {
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: n.npcRegistry.Digest()})
seeded := n.npcResolver.Seeded()
if seeded.Bound() {
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints
}
@@ -107,7 +109,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context error before normalize: %w", err)
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, n.npcRegistry)
npcRegistry, err := n.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, npcRegistry)
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
}

View File

@@ -70,6 +70,36 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
}
}
func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testing.T) {
doc := testDocument()
normalizer, err := New(Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Storm",
TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"Minion"}}},
Summary: "Storm attacks",
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
}}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
Source: doc,
MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
References: npcReferences(t),
})
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
turn := result.Value.CombatTurns[0]
if turn.Actor != "Aria" || turn.Actions[0].Targets[0] != "Goblin" {
t.Fatalf("operation-normalized turn = %#v, want Aria/Goblin", turn)
}
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata)
}
}
func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50})