Simplify D&D combat turn extraction contract
This commit is contained in:
@@ -22,9 +22,7 @@ const (
|
||||
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
|
||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||
ReasonCodeTurnsReordered = "combat_turns_reordered"
|
||||
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
|
||||
@@ -122,11 +120,9 @@ type normalizedRecord struct {
|
||||
hasEvidence bool
|
||||
}
|
||||
|
||||
type targetCanonicalization struct {
|
||||
actionIndex int
|
||||
targetIndex int
|
||||
from string
|
||||
to string
|
||||
type actorCanonicalization struct {
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
|
||||
@@ -137,7 +133,7 @@ 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, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry)
|
||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
|
||||
earliest, hasEvidence := earliestSourcePosition(doc, turn)
|
||||
records[index] = normalizedRecord{
|
||||
turn: turn,
|
||||
@@ -145,13 +141,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
earliest: earliest,
|
||||
hasEvidence: hasEvidence,
|
||||
}
|
||||
if fieldsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: turnScope(index),
|
||||
ReasonCode: ReasonCodeFieldsNormalized,
|
||||
Message: fmt.Sprintf("input index %d: combat turn fields normalized", index),
|
||||
})
|
||||
}
|
||||
if actorChange != nil {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: turnScope(index),
|
||||
@@ -160,15 +149,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)),
|
||||
})
|
||||
}
|
||||
for _, targetChange := range targetChanges {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: turnScope(index),
|
||||
ReasonCode: ReasonCodeTargetCanonicalized,
|
||||
Message: fmt.Sprintf("input index %d: action %d target %d canonicalized from %s to %s",
|
||||
index, targetChange.actionIndex, targetChange.targetIndex,
|
||||
diagnostics.Quote(targetChange.from), diagnostics.Quote(targetChange.to)),
|
||||
})
|
||||
}
|
||||
if refsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: turnScope(index),
|
||||
@@ -205,87 +185,27 @@ 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, bool, *targetCanonicalization, []targetCanonicalization, bool) {
|
||||
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
|
||||
output := cloneCombatTurn(input)
|
||||
output.Actor = identity.NormalizeDisplay(input.Actor)
|
||||
output.Summary = identity.NormalizeDisplay(input.Summary)
|
||||
|
||||
var actorChange *targetCanonicalization
|
||||
if canonical, ok := registry.Lookup(output.Actor); ok {
|
||||
canonicalName := identity.NormalizeDisplay(canonical.Name)
|
||||
if output.Actor != canonicalName {
|
||||
actorChange = &targetCanonicalization{from: output.Actor, to: canonicalName}
|
||||
output.Actor = canonicalName
|
||||
}
|
||||
output.Actor = canonicalName
|
||||
}
|
||||
var actorChange *actorCanonicalization
|
||||
if input.Actor != output.Actor {
|
||||
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
|
||||
}
|
||||
|
||||
targetChanges := make([]targetCanonicalization, 0)
|
||||
for actionIndex := range output.Actions {
|
||||
action := &output.Actions[actionIndex]
|
||||
action.Declaration = identity.NormalizeDisplay(action.Declaration)
|
||||
if action.Resolution != nil {
|
||||
resolution := identity.NormalizeDisplay(*action.Resolution)
|
||||
action.Resolution = &resolution
|
||||
}
|
||||
if action.Targets == nil {
|
||||
continue
|
||||
}
|
||||
targets := make([]string, 0, len(action.Targets))
|
||||
seen := make(map[string]struct{}, len(action.Targets))
|
||||
for targetIndex, target := range action.Targets {
|
||||
normalized := identity.NormalizeDisplay(target)
|
||||
if canonical, ok := registry.Lookup(normalized); ok {
|
||||
canonicalName := identity.NormalizeDisplay(canonical.Name)
|
||||
if normalized != canonicalName {
|
||||
targetChanges = append(targetChanges, targetCanonicalization{
|
||||
actionIndex: actionIndex,
|
||||
targetIndex: targetIndex,
|
||||
from: normalized,
|
||||
to: canonicalName,
|
||||
})
|
||||
}
|
||||
normalized = canonicalName
|
||||
}
|
||||
key := identity.ComparisonKey(normalized)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
targets = append(targets, normalized)
|
||||
}
|
||||
action.Targets = targets
|
||||
}
|
||||
|
||||
fieldsChanged := input.Actor != output.Actor || input.Summary != output.Summary
|
||||
if len(input.Actions) != len(output.Actions) {
|
||||
fieldsChanged = true
|
||||
}
|
||||
for index := range output.Actions {
|
||||
if input.Actions[index].Declaration != output.Actions[index].Declaration ||
|
||||
!stringSlicesEqual(input.Actions[index].Targets, output.Actions[index].Targets) ||
|
||||
!stringPointersEqual(input.Actions[index].Resolution, output.Actions[index].Resolution) {
|
||||
fieldsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
|
||||
output.SourceRefs = canonicalRefs
|
||||
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
return output, fieldsChanged, actorChange, targetChanges, refsChanged
|
||||
return output, actorChange, refsChanged
|
||||
}
|
||||
|
||||
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
|
||||
output := input
|
||||
if input.Round != nil {
|
||||
round := *input.Round
|
||||
output.Round = &round
|
||||
}
|
||||
if input.Actions != nil {
|
||||
output.Actions = make([]dnd.CombatAction, len(input.Actions))
|
||||
for index, action := range input.Actions {
|
||||
output.Actions[index] = cloneCombatAction(action)
|
||||
}
|
||||
}
|
||||
if input.SourceRefs != nil {
|
||||
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
|
||||
copy(output.SourceRefs, input.SourceRefs)
|
||||
@@ -293,38 +213,6 @@ func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
|
||||
return output
|
||||
}
|
||||
|
||||
func cloneCombatAction(input dnd.CombatAction) dnd.CombatAction {
|
||||
output := input
|
||||
if input.Targets != nil {
|
||||
output.Targets = make([]string, len(input.Targets))
|
||||
copy(output.Targets, input.Targets)
|
||||
}
|
||||
if input.Resolution != nil {
|
||||
resolution := *input.Resolution
|
||||
output.Resolution = &resolution
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func stringSlicesEqual(left, right []string) 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
|
||||
}
|
||||
|
||||
func stringPointersEqual(left, right *string) bool {
|
||||
if (left == nil) != (right == nil) {
|
||||
return false
|
||||
}
|
||||
return left == nil || *left == *right
|
||||
}
|
||||
|
||||
func sourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
if (left == nil) != (right == nil) || len(left) != len(right) {
|
||||
return false
|
||||
@@ -454,12 +342,6 @@ func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
|
||||
writeKeyString(&key, string(turn.TurnKind))
|
||||
if turn.Round == nil {
|
||||
key.WriteByte('0')
|
||||
} else {
|
||||
key.WriteByte('1')
|
||||
writeKeyInt(&key, *turn.Round)
|
||||
}
|
||||
for _, ref := range turn.SourceRefs {
|
||||
writeKeyString(&key, ref.SourceID)
|
||||
writeKeyInt(&key, ref.StartUnitID)
|
||||
@@ -499,7 +381,7 @@ 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 and target grounding.",
|
||||
Description: "Optional normalized NPC registry used for canonical actor grounding.",
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: NPCRegistryMaxBytes,
|
||||
|
||||
@@ -24,17 +24,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
resolution := " the target is hit "
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: " aria ",
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{
|
||||
Category: dnd.CombatActionCategoryAttack,
|
||||
Declaration: " attacks\n with a sword ",
|
||||
Targets: []string{" goblin ", "goblin", " unknown combatant "},
|
||||
Resolution: &resolution,
|
||||
}},
|
||||
Summary: " Aria\n attacks ",
|
||||
Actor: " aria ",
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||
}}}
|
||||
original := cloneCombatTurn(input.CombatTurns[0])
|
||||
@@ -46,17 +38,14 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if got := result.Value.CombatTurns[0]; got.Actor != "Aria" || got.Summary != "Aria attacks" || got.Actions[0].Declaration != "attacks with a sword" || got.Actions[0].Resolution == nil || *got.Actions[0].Resolution != "the target is hit" {
|
||||
t.Fatalf("normalized turn = %#v, want display-normalized fields", got)
|
||||
}
|
||||
if got := result.Value.CombatTurns[0].Actions[0].Targets; !reflect.DeepEqual(got, []string{"Goblin", "unknown combatant"}) {
|
||||
t.Fatalf("normalized targets = %#v, want canonical deduplicated target and preserved unmatched target", got)
|
||||
if got := result.Value.CombatTurns[0]; got.Actor != "Aria" {
|
||||
t.Fatalf("normalized turn = %#v, want canonical actor", got)
|
||||
}
|
||||
wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
|
||||
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
|
||||
t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
|
||||
}
|
||||
for _, reason := range []string{ReasonCodeFieldsNormalized, ReasonCodeActorCanonicalized, ReasonCodeTargetCanonicalized, ReasonCodeSourceRefsNormalized} {
|
||||
for _, reason := range []string{ReasonCodeActorCanonicalized, ReasonCodeSourceRefsNormalized} {
|
||||
if !hasWarningReason(result.Warnings, reason) {
|
||||
t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason)
|
||||
}
|
||||
@@ -64,9 +53,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||
if !reflect.DeepEqual(input.CombatTurns[0], original) {
|
||||
t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original)
|
||||
}
|
||||
result.Value.CombatTurns[0].Actions[0].Targets[0] = "changed"
|
||||
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" {
|
||||
t.Fatal("normalized targets share input storage")
|
||||
result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999
|
||||
if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 {
|
||||
t.Fatal("normalized source refs share input storage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +68,6 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "aria",
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"goblin"}}},
|
||||
Summary: "Aria attacks",
|
||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
|
||||
}}}
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
|
||||
@@ -92,8 +79,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
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 turn.Actor != "Aria" {
|
||||
t.Fatalf("operation-normalized turn = %#v, want Aria", 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)
|
||||
@@ -103,12 +90,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
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})
|
||||
first.Summary = "first record"
|
||||
second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
|
||||
second.Summary = "later record"
|
||||
duplicate := cloneCombatTurn(first)
|
||||
duplicate.Summary = "must not replace first"
|
||||
duplicate.Actions[0].Declaration = "replacement action"
|
||||
invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}}
|
||||
|
||||
@@ -126,7 +109,7 @@ func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T
|
||||
if len(result.Value.CombatTurns) != 3 {
|
||||
t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns))
|
||||
}
|
||||
if result.Value.CombatTurns[0].Summary != "first record" || result.Value.CombatTurns[0].Actions[0].Declaration != "Aria attacks" || result.Value.CombatTurns[1].Summary != "later record" || result.Value.CombatTurns[2].Actor != "Unknown" {
|
||||
if result.Value.CombatTurns[0].SourceRefs[0].StartUnitID != 50 || result.Value.CombatTurns[1].SourceRefs[0].StartUnitID != 90 || result.Value.CombatTurns[2].Actor != "Unknown" {
|
||||
t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns)
|
||||
}
|
||||
if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||
@@ -164,14 +147,12 @@ func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
|
||||
func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
|
||||
doc := testDocument()
|
||||
base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
|
||||
base.Round = nil
|
||||
tests := []struct {
|
||||
name string
|
||||
other dnd.CombatTurn
|
||||
}{
|
||||
{name: "different actor", other: withActor(base, "Borin")},
|
||||
{name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)},
|
||||
{name: "different round", other: withRound(base, 2)},
|
||||
{name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})},
|
||||
{name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})},
|
||||
}
|
||||
@@ -291,9 +272,6 @@ func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn {
|
||||
return dnd.CombatTurn{
|
||||
Actor: actor,
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
Round: intPointer(1),
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: actor + " attacks", Targets: []string{}, Resolution: nil}},
|
||||
Summary: actor + " attacks",
|
||||
SourceRefs: []source.SourceRef{ref},
|
||||
}
|
||||
}
|
||||
@@ -329,28 +307,17 @@ func hasWarningReason(warnings []contracts.Warning, reason string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
|
||||
turn.Actor = actor
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
|
||||
turn.TurnKind = kind
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
func withRound(turn dnd.CombatTurn, round int) dnd.CombatTurn {
|
||||
turn.Round = intPointer(round)
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||
turn.SourceRefs = []source.SourceRef{ref}
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user