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

@@ -30,8 +30,6 @@ messages:
content_file: ./sharedassets/common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user
content_file: ./sharedassets/common-dnd-npcs.md
cache_control:

View File

@@ -1,7 +1,6 @@
Return the combat_turns array even when no combat turn is established. Return
one or more actions for every turn. For turn_kind, use exactly one of: turn,
reaction, legendary_action, lair_action, or other. For each action category,
use exactly one of: attack, spell, movement, item, ability_check, saving_throw,
condition, or other. Set round to null when the transcript does not state an
explicit or unambiguous positive round number. Set resolution to null when the
transcript establishes the declaration but not an immediate resolution.
actor, turn_kind, and source_refs for every record. For turn_kind, use exactly
one of: turn, reaction, legendary_action, lair_action, or other. Cite the
transcript ranges that establish both the actor and the combat event. Use the
players, party, and transcript context to map speakers to in-world actors. NPC
names may help disambiguate identity but do not replace transcript evidence.

View File

@@ -2,18 +2,17 @@ Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript.
Include a record only when the transcript establishes that an in-world
participant takes a combat turn or performs a discrete interrupting combat
event. Reactions, legendary actions, lair actions, and other out-of-turn events
belong at the point where they occur in transcript chronology.
event. Interrupting events belong at the point where they occur in transcript
chronology.
Exclude initiative setup without a turn or combat event, tactical planning,
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
of combat outside the current passage, and downstream consequences.
table talk, rules lookup, hypothetical events, abandoned intentions, recaps
outside the current passage, and downstream consequences.
Do not infer a round, target, roll, amount, condition, outcome, or action
classification from D&D rules knowledge. Preserve the session as played;
attribute relevant nonstandard rulings to the GM or table.
Do not infer combat events from D&D rules knowledge. Preserve the session as
played and attribute relevant nonstandard rulings to the GM or table.
Unmatched actors and targets remain permitted.
Unmatched actors remain permitted.
Place all supporting transcript ranges for a turn in its turn-level source_refs
collection.

View File

@@ -10,7 +10,7 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
"required": ["actor", "turn_kind", "source_refs"],
"properties": {
"actor": {
"type": "string"
@@ -18,37 +18,6 @@
"turn_kind": {
"type": "string"
},
"round": {
"type": ["integer", "null"]
},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["category", "declaration", "targets", "resolution"],
"properties": {
"category": {
"type": "string"
},
"declaration": {
"type": "string"
},
"targets": {
"type": "array",
"items": {
"type": "string"
}
},
"resolution": {
"type": ["string", "null"]
}
}
}
},
"summary": {
"type": "string"
},
"source_refs": {
"type": "array",
"items": {

View File

@@ -96,9 +96,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
turns[index] = dnd.CombatTurn{
Actor: turn.Actor,
TurnKind: dnd.CombatTurnKind(turn.TurnKind),
Round: cloneIntPointer(turn.Round),
Actions: canonicalActions(turn.Actions),
Summary: turn.Summary,
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
}
}
@@ -108,22 +105,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
return dnd.CombatTurnList{CombatTurns: turns}
}
func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
if actions == nil {
return nil
}
out := make([]dnd.CombatAction, len(actions))
for index, action := range actions {
out[index] = dnd.CombatAction{
Category: dnd.CombatActionCategory(action.Category),
Declaration: action.Declaration,
Targets: append([]string(nil), action.Targets...),
Resolution: cloneStringPointer(action.Resolution),
}
}
return out
}
func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil {
return nil
@@ -134,19 +115,3 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
}
return out
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
out := *value
return &out
}
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
out := *value
return &out
}

View File

@@ -43,7 +43,7 @@ func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, 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,

View File

@@ -17,26 +17,20 @@ import (
)
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
round := 3
resolution := "The ogre falls back."
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
{
Actor: "Borin", TurnKind: "turn", Round: &round,
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
Actor: "Borin", TurnKind: "turn",
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{
Actor: "Aria", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
{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: 0, EndUnitID: 0}},
Actor: "Unknown", TurnKind: "other",
SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
},
}}}
@@ -51,9 +45,6 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
}
if result.Value.CombatTurns[0].Round != nil || result.Value.CombatTurns[0].Actions[0].Resolution == nil || *result.Value.CombatTurns[0].Actions[0].Resolution != resolution {
t.Fatalf("nullable fields = %#v, want nil round and preserved resolution", result.Value.CombatTurns[0])
}
if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) {
t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref)
}
@@ -72,13 +63,10 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
}
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
negativeRound := -1
emptyResolution := " "
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
{
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
Actor: " ", TurnKind: "unsupported",
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
},
}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
@@ -86,12 +74,9 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
}
turn := result.Value.CombatTurns[0]
if turn.Actor != " " || turn.TurnKind != "unsupported" || turn.Round == nil || *turn.Round != negativeRound || turn.Summary != " " {
if turn.Actor != " " || turn.TurnKind != "unsupported" {
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
}
if turn.Actions == nil || turn.Actions[0].Targets != nil || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != emptyResolution {
t.Fatalf("invalid action fields = %#v, want preserved candidate values", turn.Actions[0])
}
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0])
}

View File

@@ -1,11 +1,5 @@
package combatturns
import (
"bytes"
"encoding/json"
"fmt"
)
type extractionResponse struct {
CombatTurns []combatTurnResponse `json:"combat_turns"`
}
@@ -13,93 +7,10 @@ type extractionResponse struct {
type combatTurnResponse struct {
Actor string `json:"actor"`
TurnKind string `json:"turn_kind"`
Round *int `json:"round"`
Actions []combatActionResponse `json:"actions"`
Summary string `json:"summary"`
SourceRefs []combatSourceRefResponse `json:"source_refs"`
}
type combatActionResponse struct {
Category string `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution *string `json:"resolution"`
}
type combatSourceRefResponse struct {
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

@@ -2,57 +2,20 @@ 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}]}]}`)
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","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)
t.Fatalf("json.Unmarshal() error = %v", 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.Actor != "" || turn.TurnKind != "unsupported" {
t.Fatalf("decoded turn = %#v", 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)
}
})
t.Fatalf("decoded ref = %#v", turn.SourceRefs[0])
}
}

View File

@@ -46,13 +46,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
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
@@ -63,7 +56,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
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 {
@@ -83,12 +75,10 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
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: "missing actor", mutate: func(turn map[string]any) { delete(turn, "actor") }},
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
{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")
}},
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := validCombatResponse()
@@ -134,11 +124,8 @@ func validCombatResponse() map[string]any {
return map[string]any{
"combat_turns": []any{
map[string]any{
"actor": "Aria", "turn_kind": "reaction", "round": nil,
"actions": []any{map[string]any{
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
}},
"summary": "Aria reacts.",
"actor": "Aria",
"turn_kind": "reaction",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
},
},

View File

@@ -24,7 +24,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.md",
},
}