Centralize D&D source reference ordering

This commit is contained in:
2026-07-24 14:20:10 +00:00
parent 6e21c83fd8
commit 1ff449435f
16 changed files with 363 additions and 460 deletions

View File

@@ -14,12 +14,13 @@ 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"
)
const (
Key = "dnd/combat-turns"
normalizationPolicy = "dnd.combat_turns.normalize.v1"
normalizationPolicy = "dnd.combat_turns.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
@@ -109,7 +110,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err != nil {
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, npcRegistry)
order := shared.NewSourceRefOrder(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, order, npcRegistry)
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
}
@@ -125,7 +127,7 @@ type actorCanonicalization struct {
to string
}
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
if input.CombatTurns == nil {
return dnd.CombatTurnList{}, nil
}
@@ -133,8 +135,8 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
records := make([]normalizedRecord, len(input.CombatTurns))
warnings := make([]contracts.Warning, 0)
for index, inputTurn := range input.CombatTurns {
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
earliest, hasEvidence := earliestSourcePosition(doc, turn)
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
records[index] = normalizedRecord{
turn: turn,
inputIndex: index,
@@ -185,7 +187,7 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
return dnd.CombatTurnList{CombatTurns: output}, warnings
}
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
output := cloneCombatTurn(input)
output.Actor = identity.NormalizeDisplay(input.Actor)
@@ -198,7 +200,7 @@ func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.Co
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
}
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
canonicalRefs, _, _ := canonicalizeSourceRefs(order, input.SourceRefs)
output.SourceRefs = canonicalRefs
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, actorChange, refsChanged
@@ -225,62 +227,9 @@ func sourceRefsEqual(left, right []source.SourceRef) bool {
return true
}
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil {
return nil, false, 0
}
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right])
})
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
orderChanged = true
break
}
}
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique, orderChanged, len(input) - len(unique)
}
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
}
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
if doc == nil {
return 0, false
}
earliest := 0
found := false
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
index, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
canonical := order.Canonicalize(input)
return canonical, !sourceRefsEqual(input, canonical), len(input) - len(canonical)
}
type duplicateGroup struct {

View File

@@ -122,6 +122,34 @@ func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T
}
}
func TestNormalizeUsesDocumentOrderForReferencesAndChronology(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}),
{Actor: "Goblin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
}},
}}
normalizer, err := New(Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if got := result.Value.CombatTurns; got[0].Actor != "Aria" || got[1].Actor != "Goblin" || got[2].Actor != "Borin" {
t.Fatalf("turn order = %#v, want source chronology", got)
}
refs := result.Value.CombatTurns[1].SourceRefs
if refs[0].StartUnitID != 30 || refs[1].StartUnitID != 10 || refs[2].StartUnitID != 999 {
t.Fatalf("turn source refs = %#v, want source-document order and invalid fallback", refs)
}
}
func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{