Simplify D&D combat turn extraction contract

This commit is contained in:
2026-07-22 19:14:14 +00:00
parent 2cbaf20e55
commit 90481a0e4b
36 changed files with 176 additions and 1070 deletions

View File

@@ -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,