Close out the D&D combat turn extraction roadmap
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal file
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user