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

@@ -13,66 +13,54 @@
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
"properties": {
"actor": {
"type": "string",
"minLength": 1
"type": "string"
},
"turn_kind": {
"type": "string",
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
"type": "string"
},
"round": {
"type": ["integer", "null"],
"minimum": 1
"type": ["integer", "null"]
},
"actions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["category", "declaration", "targets", "resolution"],
"properties": {
"category": {
"type": "string",
"enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"]
"type": "string"
},
"declaration": {
"type": "string",
"minLength": 1
"type": "string"
},
"targets": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
"type": "string"
}
},
"resolution": {
"type": ["string", "null"],
"minLength": 1
"type": ["string", "null"]
}
}
}
},
"summary": {
"type": "string",
"minLength": 1
"type": "string"
},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {
"type": "integer",
"minimum": 1
"type": "integer"
},
"end_unit_id": {
"type": "integer",
"minimum": 1
"type": "integer"
}
}
}

View File

@@ -59,7 +59,7 @@ func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse
}
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
return left.StartUnitID.Int() == right.StartUnitID.Int() && left.EndUnitID.Int() == right.EndUnitID.Int()
return left == right
}
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
@@ -69,7 +69,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse)
earliest := 0
found := false
for _, ref := range turn.SourceRefs {
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()}
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
if err := source.ValidateRef(doc, candidate); err != nil {
continue
}
@@ -83,8 +83,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse)
return earliest, found
}
func unitSortValue(ref interface{ Int() int }) int {
value := ref.Int()
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
}
@@ -121,9 +120,6 @@ func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
Targets: append([]string(nil), action.Targets...),
Resolution: cloneStringPointer(action.Resolution),
}
if action.Targets != nil {
out[index].Targets = append([]string{}, action.Targets...)
}
}
return out
}
@@ -134,7 +130,7 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
}
out := make([]source.SourceRef, len(refs))
for index, ref := range refs {
out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()}
out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return out
}

View File

@@ -14,7 +14,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
@@ -24,20 +23,20 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
{
Actor: "Borin", TurnKind: "turn", Round: &round,
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)}},
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
},
{
Actor: "Aria", TurnKind: "reaction", Round: nil,
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 10, EndUnitID: 10},
},
},
{
Actor: "Unknown", TurnKind: "other", Round: nil,
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromString("missing"), EndUnitID: shared.UnitRefFromString("missing")}},
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
},
}}}
@@ -79,7 +78,7 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
{
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("not-a-unit")}},
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
},
}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())

View File

@@ -1,6 +1,10 @@
package combatturns
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
import (
"bytes"
"encoding/json"
"fmt"
)
type extractionResponse struct {
CombatTurns []combatTurnResponse `json:"combat_turns"`
@@ -23,6 +27,79 @@ type combatActionResponse struct {
}
type combatSourceRefResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"`
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
func (response *combatTurnResponse) UnmarshalJSON(content []byte) error {
type responseWire struct {
Actor string `json:"actor"`
TurnKind string `json:"turn_kind"`
Round json.RawMessage `json:"round"`
Actions []combatActionResponse `json:"actions"`
Summary string `json:"summary"`
SourceRefs []combatSourceRefResponse `json:"source_refs"`
}
var wire responseWire
if err := json.Unmarshal(content, &wire); err != nil {
return err
}
round, err := decodeRequiredNullableInt(wire.Round, "round")
if err != nil {
return err
}
*response = combatTurnResponse{
Actor: wire.Actor, TurnKind: wire.TurnKind, Round: round, Actions: wire.Actions,
Summary: wire.Summary, SourceRefs: wire.SourceRefs,
}
return nil
}
func (response *combatActionResponse) UnmarshalJSON(content []byte) error {
type responseWire struct {
Category string `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution json.RawMessage `json:"resolution"`
}
var wire responseWire
if err := json.Unmarshal(content, &wire); err != nil {
return err
}
resolution, err := decodeRequiredNullableString(wire.Resolution, "resolution")
if err != nil {
return err
}
*response = combatActionResponse{
Category: wire.Category, Declaration: wire.Declaration, Targets: wire.Targets, Resolution: resolution,
}
return nil
}
func decodeRequiredNullableInt(raw json.RawMessage, field string) (*int, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, nil
}
var value int
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
}
return &value, nil
}
func decodeRequiredNullableString(raw json.RawMessage, field string) (*string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
}
return &value, nil
}

View File

@@ -0,0 +1,58 @@
package combatturns
import (
"encoding/json"
"strings"
"testing"
)
func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) {
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","round":-1,"actions":[{"category":"unsupported","declaration":"","targets":[],"resolution":""}],"summary":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
var response extractionResponse
if err := json.Unmarshal(content, &response); err != nil {
t.Fatalf("json.Unmarshal() error = %v, want semantic candidate", err)
}
turn := response.CombatTurns[0]
if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" {
t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn)
}
if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", turn.SourceRefs[0])
}
}
func TestExtractionResponseDecodingDistinguishesMissingAndNullNullableFields(t *testing.T) {
validNulls := []byte(`{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[],"resolution":null}],"summary":"attacks","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
var response extractionResponse
if err := json.Unmarshal(validNulls, &response); err != nil {
t.Fatalf("json.Unmarshal(nulls) error = %v", err)
}
if response.CombatTurns[0].Round != nil || response.CombatTurns[0].Actions[0].Resolution != nil {
t.Fatalf("decoded nullables = %#v, want explicit null", response.CombatTurns[0])
}
for _, test := range []struct {
name string
content string
field string
}{
{
name: "missing round",
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","actions":[],"summary":"attacks","source_refs":[]}]}`,
field: "round",
},
{
name: "missing resolution",
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[]}],"summary":"attacks","source_refs":[]}]}`,
field: "resolution",
},
} {
t.Run(test.name, func(t *testing.T) {
var candidate extractionResponse
err := json.Unmarshal([]byte(test.content), &candidate)
if err == nil || !strings.Contains(err.Error(), test.field) {
t.Fatalf("json.Unmarshal() error = %v, want missing %s failure", err, test.field)
}
})
}
}

View File

@@ -37,6 +37,73 @@ func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
}
}
func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
semanticCandidate := validCombatResponse()
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
turn["actor"] = ""
turn["turn_kind"] = "unsupported"
turn["round"] = -1
turn["summary"] = ""
action := turn["actions"].([]any)[0].(map[string]any)
action["category"] = "unsupported"
action["declaration"] = ""
action["targets"] = []any{""}
action["resolution"] = ""
ref := turn["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0
ref["end_unit_id"] = -1
content, err := json.Marshal(semanticCandidate)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
}
turn["actions"] = []any{}
turn["source_refs"] = []any{}
content, err = json.Marshal(semanticCandidate)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("private schema rejected empty validator-owned collections: %v", err)
}
}
func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
mutate func(map[string]any)
}{
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }},
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }},
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
{name: "missing nullable resolution", mutate: func(turn map[string]any) {
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := validCombatResponse()
test.mutate(candidate["combat_turns"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err == nil {
t.Fatal("private schema accepted structurally invalid response")
}
})
}
}
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
first, err := loadResponseSchema()
if err != nil {

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 {

View File

@@ -125,6 +125,33 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
}
}
func TestCombatNormalizerRejectsCampaignReferenceBinding(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)
referencePath := filepath.Join(t.TempDir(), "party.yml")
if err := os.WriteFile(referencePath, []byte("Aria: cleric\n"), 0o600); err != nil {
t.Fatal(err)
}
effective, err := combatOnlyConfig().Resolve(config.ResolveInput{
PipelineID: "dnd-combat-fixture",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{{
Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "party", Source: referencePath,
BindingSource: contracts.ReferenceBindingSourceCLI,
}},
})
if err != nil {
if !strings.Contains(err.Error(), "party") {
t.Fatalf("Resolve() error = %v, want undeclared party context", err)
}
return
}
_, _, err = pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
if err == nil || !strings.Contains(err.Error(), "party") || !strings.Contains(err.Error(), "not declared") {
t.Fatalf("MaterializeReferences() error = %v, want undeclared normalize party binding", err)
}
}
func TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)