Close out the D&D combat turn extraction roadmap

This commit is contained in:
2026-07-21 07:49:57 -05:00
parent a1f5dce405
commit 110593ece1
15 changed files with 318 additions and 804 deletions

View File

@@ -14,7 +14,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
@@ -38,13 +37,6 @@ const (
var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"}
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
Party: "Optional party roster reference material used only for disambiguation.",
Players: "Optional player list reference material used only for disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
}
var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
@@ -483,39 +475,29 @@ func writeKeyInt(builder *strings.Builder, value int) {
}
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
const maxDisplayedIndices = 20
displayed := removed
if len(displayed) > maxDisplayedIndices {
displayed = displayed[:maxDisplayedIndices]
}
indices := make([]string, len(displayed))
for index, removedIndex := range displayed {
indices[index] = strconv.Itoa(removedIndex)
}
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
if omitted := len(removed) - len(displayed); omitted > 0 {
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
issues := make([]string, len(removed))
for index, removedIndex := range removed {
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
}
return contracts.Warning{
Scope: turnScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Truncate(message),
Message: diagnostics.Aggregate(
fmt.Sprintf("duplicate combat turn collapsed; retained input index %d", retainedIndex),
issues,
),
}
}
func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index) }
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{
return []contracts.ReferenceSlot{{
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
AcceptedMediaTypes: []string{"application/json"},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
return slots
}}
}
func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -2,8 +2,12 @@ package combatturns
import (
"context"
"fmt"
"math"
"reflect"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -203,7 +207,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" {
t.Fatalf("bound fingerprints = %#v", got)
}
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 5 {
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 1 || spec.ReferenceSlots[0].Name != NPCRegistryReferenceSlot {
t.Fatalf("ModuleSpec() = %#v", spec)
}
registry := pipeline.NewNormalizerRegistry()
@@ -218,6 +222,26 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
}
}
func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) {
removed := make([]int, 25)
for index := range removed {
removed[index] = math.MaxInt - index
}
warning := duplicateWarning(7, removed)
if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed {
t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning)
}
if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) {
t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message)
}
if !strings.Contains(warning.Message, "5 additional issue(s) omitted") {
t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message)
}
if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 {
t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message))
}
}
func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) {
normalizer, err := New(Options{})
if err != nil {