// Package combatturns normalizes merged D&D combat-turn candidates. package combatturns import ( "context" "fmt" "sort" "strconv" "strings" "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" 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" ) const ( Key = "dnd/combat-turns" normalizationPolicy = "dnd.combat_turns.normalize.v2" NormalizationPolicy = normalizationPolicy ReasonCodeActorCanonicalized = "combat_actor_canonicalized" ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeTurnsReordered = "combat_turns_reordered" ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed" ) const ( NPCRegistryReferenceSlot = npcregistry.ReferenceSlot NPCRegistryMaxBytes = npcregistry.MaxBytes ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct { npcResolver *npcregistry.Resolver } func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) { if len(references) > 1 { return nil, normalizerErrorf("at most one reference set may be supplied") } var referenceSet contracts.ReferenceSet if len(references) == 1 { referenceSet = references[0] } npcResolver, err := npcregistry.NewResolver(referenceSet) if err != nil { return nil, normalizerErrorf("prepare NPC registry: %w", err) } return &Normalizer{npcResolver: npcResolver}, nil } func (n *Normalizer) Key() string { return Key } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } func (n *Normalizer) ManifestMetadata() map[string]any { if n == nil { return nil } metadata := map[string]any{ "normalization_policy": normalizationPolicy, "identity_policy": identity.Policy, } seeded := n.npcResolver.Seeded() if seeded.Bound() { metadata["npc_registry_digest"] = seeded.Digest() metadata["npc_count"] = seeded.Count() } return metadata } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } fingerprints := []pipeline.CheckpointFingerprint{ {Name: "normalization_policy", Value: normalizationPolicy}, {Name: "identity_policy", Value: identity.Policy}, } seeded := n.npcResolver.Seeded() fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()}) return fingerprints } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.CombatTurnList]) (contracts.TypedNormalizeResult[dnd.CombatTurnList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context error before normalize: %w", err) } npcRegistry, err := n.npcResolver.Resolve(req.References) if err != nil { return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err) } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry) return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { turn dnd.CombatTurn inputIndex int earliest int hasEvidence bool } type actorCanonicalization struct { from string to string } func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) { if input.CombatTurns == nil { return dnd.CombatTurnList{}, nil } records := make([]normalizedRecord, len(input.CombatTurns)) warnings := make([]contracts.Warning, 0) for index, inputTurn := range input.CombatTurns { turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry) earliest, hasEvidence := order.EarliestValid(turn.SourceRefs) records[index] = normalizedRecord{ turn: turn, inputIndex: index, earliest: earliest, hasEvidence: hasEvidence, } if actorChange != nil { warnings = append(warnings, contracts.Warning{ Scope: turnScope(index), ReasonCode: ReasonCodeActorCanonicalized, Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s", index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)), }) } if refsChanged { warnings = append(warnings, contracts.Warning{ Scope: turnScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputTurn.SourceRefs), len(turn.SourceRefs)), }) } } sort.SliceStable(records, func(left, right int) bool { if records[left].hasEvidence != records[right].hasEvidence { return records[left].hasEvidence } if !records[left].hasEvidence { return false } return records[left].earliest < records[right].earliest }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ Scope: turnScope(record.inputIndex), ReasonCode: ReasonCodeTurnsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), }) } output, duplicateWarnings := collapseDuplicates(records, documentIndex) warnings = append(warnings, duplicateWarnings...) return dnd.CombatTurnList{CombatTurns: output}, warnings } func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) { output := cloneCombatTurn(input) output.Actor = identity.NormalizeDisplay(input.Actor) if canonical, ok := registry.Lookup(output.Actor); ok { canonicalName := identity.NormalizeDisplay(canonical.Name) output.Actor = canonicalName } var actorChange *actorCanonicalization if input.Actor != output.Actor { actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor} } output.SourceRefs = order.Canonicalize(input.SourceRefs) refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs) return output, actorChange, refsChanged } func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn { output := input if input.SourceRefs != nil { output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) copy(output.SourceRefs, input.SourceRefs) } return output } 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 } type duplicateGroup struct { retainedIndex int removed []int } func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []contracts.Warning) { if len(records) == 0 { return make([]dnd.CombatTurn, 0), nil } keep := make([]bool, len(records)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for index, record := range records { key, eligible := duplicateKey(record.turn, documentIndex) if !eligible { keep[index] = true continue } groupIndex, exists := groupByKey[key] if !exists { groupByKey[key] = len(groups) groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) keep[index] = true continue } groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) } output := make([]dnd.CombatTurn, 0, len(records)) for index, record := range records { if keep[index] { output = append(output, cloneCombatTurn(record.turn)) } } warnings := make([]contracts.Warning, 0) for _, group := range groups { if len(group.removed) == 0 { continue } warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) } return output, warnings } func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) { if len(turn.SourceRefs) == 0 { return "", false } for _, ref := range turn.SourceRefs { if documentIndex.ValidateRef(ref) != nil { return "", false } } var key strings.Builder writeKeyString(&key, identity.ComparisonKey(turn.Actor)) writeKeyString(&key, string(turn.TurnKind)) for _, ref := range turn.SourceRefs { writeKeyString(&key, ref.SourceID) writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.EndUnitID) } return key.String(), true } 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(';') } func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { 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.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 { return []contracts.ReferenceSlot{{ Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical actor grounding.", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes, }} } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.CombatTurnListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.CombatTurnList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options, request.References) }) } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options) return err } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, normalizerErrorf("%w", err) } return Options{}, nil } func normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd combat turns normalizer: "+format, args...) }