Simplify D&D combat turn extraction contract
This commit is contained in:
@@ -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",
|
||||
@@ -20,44 +20,6 @@
|
||||
"type": "string",
|
||||
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
|
||||
},
|
||||
"round": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"declaration": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
@@ -79,107 +78,21 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
||||
}
|
||||
|
||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||
// validators have approved it. Nullable round and resolution fields are
|
||||
// decoded through raw values so a missing key is not confused with null.
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
var wire combatTurnListWire
|
||||
if err := decoder.Decode(&wire); err != nil {
|
||||
var value dnd.CombatTurnList
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
|
||||
}
|
||||
|
||||
value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, len(wire.CombatTurns))}
|
||||
if wire.CombatTurns == nil {
|
||||
value.CombatTurns = nil
|
||||
}
|
||||
for index, turn := range wire.CombatTurns {
|
||||
round, err := decodeNullableInt(turn.Round, fmt.Sprintf("combat_turns[%d].round", index))
|
||||
if err != nil {
|
||||
return dnd.CombatTurnList{}, err
|
||||
}
|
||||
actions := make([]dnd.CombatAction, len(turn.Actions))
|
||||
if turn.Actions == nil {
|
||||
actions = nil
|
||||
}
|
||||
for actionIndex, action := range turn.Actions {
|
||||
resolution, err := decodeNullableString(action.Resolution, fmt.Sprintf("combat_turns[%d].actions[%d].resolution", index, actionIndex))
|
||||
if err != nil {
|
||||
return dnd.CombatTurnList{}, err
|
||||
}
|
||||
actions[actionIndex] = dnd.CombatAction{
|
||||
Category: action.Category,
|
||||
Declaration: action.Declaration,
|
||||
Targets: action.Targets,
|
||||
Resolution: resolution,
|
||||
}
|
||||
}
|
||||
value.CombatTurns[index] = dnd.CombatTurn{
|
||||
Actor: turn.Actor,
|
||||
TurnKind: turn.TurnKind,
|
||||
Round: round,
|
||||
Actions: actions,
|
||||
Summary: turn.Summary,
|
||||
SourceRefs: turn.SourceRefs,
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type combatTurnListWire struct {
|
||||
CombatTurns []combatTurnWire `json:"combat_turns"`
|
||||
}
|
||||
|
||||
type combatTurnWire struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
|
||||
Round json.RawMessage `json:"round"`
|
||||
Actions []combatActionWire `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type combatActionWire struct {
|
||||
Category dnd.CombatActionCategory `json:"category"`
|
||||
Declaration string `json:"declaration"`
|
||||
Targets []string `json:"targets"`
|
||||
Resolution json.RawMessage `json:"resolution"`
|
||||
}
|
||||
|
||||
func decodeNullableInt(raw json.RawMessage, field string) (*int, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%s must be present", field)
|
||||
}
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var value int
|
||||
if err := json.Unmarshal(trimmed, &value); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func decodeNullableString(raw json.RawMessage, field string) (*string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%s must be present", field)
|
||||
}
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(trimmed, &value); err != nil {
|
||||
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func validate(value dnd.CombatTurnList) error {
|
||||
if value.CombatTurns == nil {
|
||||
return fmt.Errorf("combat_turns must be present")
|
||||
@@ -192,38 +105,9 @@ func validate(value dnd.CombatTurnList) error {
|
||||
if !validTurnKind(turn.TurnKind) {
|
||||
return fmt.Errorf("%s.turn_kind must be supported", prefix)
|
||||
}
|
||||
if turn.Round != nil && *turn.Round <= 0 {
|
||||
return fmt.Errorf("%s.round must be positive or null", prefix)
|
||||
}
|
||||
if len(turn.Actions) == 0 {
|
||||
return fmt.Errorf("%s.actions must contain at least one action", prefix)
|
||||
}
|
||||
if strings.TrimSpace(turn.Summary) == "" {
|
||||
return fmt.Errorf("%s.summary must not be empty", prefix)
|
||||
}
|
||||
if len(turn.SourceRefs) == 0 {
|
||||
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||
}
|
||||
for actionIndex, action := range turn.Actions {
|
||||
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||
if !validActionCategory(action.Category) {
|
||||
return fmt.Errorf("%s.category must be supported", actionPrefix)
|
||||
}
|
||||
if strings.TrimSpace(action.Declaration) == "" {
|
||||
return fmt.Errorf("%s.declaration must not be empty", actionPrefix)
|
||||
}
|
||||
if action.Targets == nil {
|
||||
return fmt.Errorf("%s.targets must be present", actionPrefix)
|
||||
}
|
||||
for targetIndex, target := range action.Targets {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return fmt.Errorf("%s.targets[%d] must not be empty", actionPrefix, targetIndex)
|
||||
}
|
||||
}
|
||||
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
|
||||
return fmt.Errorf("%s.resolution must not be empty or null", actionPrefix)
|
||||
}
|
||||
}
|
||||
for refIndex, ref := range turn.SourceRefs {
|
||||
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
@@ -248,12 +132,3 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validActionCategory(value dnd.CombatActionCategory) bool {
|
||||
switch value {
|
||||
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func validList() dnd.CombatTurnList {
|
||||
round := 1
|
||||
resolution := "The wight is hit."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria",
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
Round: &round,
|
||||
Actions: []dnd.CombatAction{{
|
||||
Category: dnd.CombatActionCategoryAttack,
|
||||
Declaration: "Aria swings her sword",
|
||||
Targets: []string{"wight"},
|
||||
Resolution: &resolution,
|
||||
}},
|
||||
Summary: "Aria attacks the wight.",
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
}
|
||||
@@ -35,30 +24,26 @@ func validList() dnd.CombatTurnList {
|
||||
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read durable fixture: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
codec := New()
|
||||
value, err := codec.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode() error = %v, want nil", err)
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
if want := validList(); !reflect.DeepEqual(value, want) {
|
||||
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||
}
|
||||
encoded, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, raw); err != nil {
|
||||
t.Fatalf("compact durable fixture: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(encoded, compact.Bytes()) {
|
||||
t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes())
|
||||
}
|
||||
second, err := codec.Encode(value)
|
||||
if err != nil || !bytes.Equal(second, encoded) {
|
||||
t.Fatalf("second Encode() = %s, %v; want deterministic bytes", second, err)
|
||||
t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +54,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
||||
}
|
||||
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("schema = %#v, want durable combat-turn schema", schema)
|
||||
t.Fatalf("schema = %#v", schema)
|
||||
}
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID {
|
||||
t.Fatalf("durable schema document = %#v, %v", document, err)
|
||||
}
|
||||
|
||||
registry := pipeline.NewArtifactCodecRegistry()
|
||||
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, ok := registry.Spec(dnd.CombatTurnListKind)
|
||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||
@@ -86,176 +66,59 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
||||
codec := New()
|
||||
validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}],"summary":"attack","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "malformed", raw: `{`, want: "decode dnd combat turn list"},
|
||||
{name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"},
|
||||
{name: "unknown turn field", raw: strings.Replace(validJSON, `"summary":"attack"`, `"summary":"attack","unexpected":true`, 1), want: "unknown field"},
|
||||
{name: "unknown action field", raw: strings.Replace(validJSON, `"resolution":"hits"`, `"resolution":"hits","unexpected":true`, 1), want: "unknown field"},
|
||||
{name: "unknown source reference field", raw: strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), want: "unknown field"},
|
||||
{name: "trailing", raw: `{"combat_turns":[]} {}`, want: "multiple JSON values"},
|
||||
{name: "missing top-level array", raw: `{}`, want: "combat_turns must be present"},
|
||||
{name: "missing round", raw: strings.Replace(validJSON, `,"round":1`, ``, 1), want: "round must be present"},
|
||||
{name: "missing resolution", raw: strings.Replace(validJSON, `,"resolution":"hits"`, ``, 1), want: "resolution must be present"},
|
||||
{name: "invalid round type", raw: strings.Replace(validJSON, `"round":1`, `"round":1.5`, 1), want: "round must be an integer or null"},
|
||||
{name: "unsupported turn kind", raw: strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), want: "turn_kind must be supported"},
|
||||
{name: "empty actions", raw: strings.Replace(validJSON, `"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}]`, `"actions":[]`, 1), want: "actions must contain at least one action"},
|
||||
{name: "empty target", raw: strings.Replace(validJSON, `"targets":["wight"]`, `"targets":[" "]`, 1), want: "targets[0] must not be empty"},
|
||||
func TestCodecStrictlyRejectsMalformedUnknownAndInvalidJSON(t *testing.T) {
|
||||
validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct{ name, raw, want string }{
|
||||
{"malformed", `{`, "decode dnd combat turn list"},
|
||||
{"unknown top-level", `{"combat_turns":[],"unexpected":true}`, "unknown field"},
|
||||
{"unknown turn field", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"turn","unexpected":true`, 1), "unknown field"},
|
||||
{"unknown source field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||
{"trailing", `{"combat_turns":[]} {}`, "multiple JSON values"},
|
||||
{"missing list", `{}`, "combat_turns must be present"},
|
||||
{"unsupported kind", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), "turn_kind must be supported"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := codec.Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
||||
round := -1
|
||||
resolution := " "
|
||||
candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: " ",
|
||||
TurnKind: "unsupported",
|
||||
Round: &round,
|
||||
Actions: []dnd.CombatAction{{
|
||||
Category: "unsupported",
|
||||
Declaration: " ",
|
||||
Targets: nil,
|
||||
Resolution: &resolution,
|
||||
}},
|
||||
Summary: " ",
|
||||
SourceRefs: []source.SourceRef{{
|
||||
SourceID: "",
|
||||
StartUnitID: 0,
|
||||
EndUnitID: -1,
|
||||
}},
|
||||
}}}
|
||||
|
||||
content, err := New().EncodeCandidate(candidate)
|
||||
if err != nil || !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err)
|
||||
func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *testing.T) {
|
||||
candidates := []dnd.CombatTurnList{
|
||||
{},
|
||||
{CombatTurns: []dnd.CombatTurn{}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: nil}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||
}
|
||||
decoded, err := New().DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
for _, candidate := range candidates {
|
||||
content, err := New().EncodeCandidate(candidate)
|
||||
if err != nil || !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||
}
|
||||
decoded, err := New().DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidatePreservesNilAndPresentEmptyArrays(t *testing.T) {
|
||||
codec := New()
|
||||
for name, value := range map[string]dnd.CombatTurnList{
|
||||
"nil combat turns": {},
|
||||
"empty combat turns": {CombatTurns: []dnd.CombatTurn{}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
content, err := codec.EncodeCandidate(value)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withoutTargets := validList()
|
||||
withoutTargets.CombatTurns[0].Actions[0].Targets = nil
|
||||
withEmptyTargets := validList()
|
||||
withEmptyTargets.CombatTurns[0].Actions[0].Targets = []string{}
|
||||
for name, value := range map[string]dnd.CombatTurnList{
|
||||
"nil targets": withoutTargets,
|
||||
"empty targets": withEmptyTargets,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
content, err := codec.EncodeCandidate(value)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withoutActions := validList()
|
||||
withoutActions.CombatTurns[0].Actions = nil
|
||||
withEmptyActions := validList()
|
||||
withEmptyActions.CombatTurns[0].Actions = []dnd.CombatAction{}
|
||||
withoutSourceRefs := validList()
|
||||
withoutSourceRefs.CombatTurns[0].SourceRefs = nil
|
||||
withEmptySourceRefs := validList()
|
||||
withEmptySourceRefs.CombatTurns[0].SourceRefs = []source.SourceRef{}
|
||||
for name, value := range map[string]dnd.CombatTurnList{
|
||||
"nil actions": withoutActions,
|
||||
"empty actions": withEmptyActions,
|
||||
"nil source refs": withoutSourceRefs,
|
||||
"empty source refs": withEmptySourceRefs,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
content, err := codec.EncodeCandidate(value)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecAcceptsExplicitNullableFieldsAndEmptyTargets(t *testing.T) {
|
||||
value := validList()
|
||||
value.CombatTurns[0].Round = nil
|
||||
value.CombatTurns[0].Actions[0].Resolution = nil
|
||||
value.CombatTurns[0].Actions[0].Targets = []string{}
|
||||
|
||||
codec := New()
|
||||
content, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil for explicit nullable fields", err)
|
||||
}
|
||||
decoded, err := codec.Decode(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
||||
func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value dnd.CombatTurnList
|
||||
want string
|
||||
}{
|
||||
{name: "nil combat turns", value: dnd.CombatTurnList{}, want: "combat_turns must be present"},
|
||||
{name: "empty actor", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }), want: "actor must not be empty"},
|
||||
{name: "unsupported turn kind", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unsupported" }), want: "turn_kind must be supported"},
|
||||
{name: "non-positive round", value: mutate(validList(), func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }), want: "round must be positive or null"},
|
||||
{name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"},
|
||||
{name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"},
|
||||
{name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"},
|
||||
{name: "nil source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }), want: "source_refs must contain at least one reference"},
|
||||
{name: "empty source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = []source.SourceRef{} }), want: "source_refs must contain at least one reference"},
|
||||
{name: "unsupported action category", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unsupported" }), want: "category must be supported"},
|
||||
{name: "empty declaration", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }), want: "declaration must not be empty"},
|
||||
{name: "nil targets", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }), want: "targets must be present"},
|
||||
{name: "empty target", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }), want: "targets[0] must not be empty"},
|
||||
{name: "empty resolution", value: mutate(validList(), func(value *dnd.CombatTurnList) {
|
||||
resolution := " "
|
||||
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||
}), want: "resolution must not be empty or null"},
|
||||
{name: "empty source ID", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].SourceID = " " }), want: "source_id must not be empty"},
|
||||
{name: "non-positive source start", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), want: "start_unit_id must be positive"},
|
||||
{name: "non-positive source end", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), want: "end_unit_id must be positive"},
|
||||
{"nil combat turns", dnd.CombatTurnList{}, "combat_turns must be present"},
|
||||
{"empty actor", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].Actor = " " }), "actor must not be empty"},
|
||||
{"unsupported turn kind", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].TurnKind = "unsupported" }), "turn_kind must be supported"},
|
||||
{"nil source refs", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs = nil }), "source_refs must contain"},
|
||||
{"empty source ID", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||
{"non-positive start", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||
{"non-positive end", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -270,14 +133,13 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||
codec := New()
|
||||
first := codec.Schema()
|
||||
first.JSONSchema[0] = '['
|
||||
second := codec.Schema()
|
||||
if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||
t.Fatal("Schema() returned shared bytes")
|
||||
}
|
||||
metadata := codec.Metadata(validList())
|
||||
metadata["other"] = true
|
||||
if next := codec.Metadata(validList()); len(next) != 1 || next["combat_turn_count"] != 1 {
|
||||
t.Fatalf("Metadata() = %#v, want only combat_turn_count", next)
|
||||
t.Fatalf("Metadata() = %#v", next)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"Aria swings her sword","targets":["wight"],"resolution":"The wight is hit."}],"summary":"Aria attacks the wight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}
|
||||
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -59,23 +59,6 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
||||
|
||||
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
clone := value
|
||||
if value.Round != nil {
|
||||
round := *value.Round
|
||||
clone.Round = &round
|
||||
}
|
||||
if value.Actions != nil {
|
||||
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
|
||||
for index, action := range value.Actions {
|
||||
clone.Actions[index] = action
|
||||
if action.Targets != nil {
|
||||
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
|
||||
}
|
||||
if action.Resolution != nil {
|
||||
resolution := *action.Resolution
|
||||
clone.Actions[index].Resolution = &resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.SourceRefs != nil {
|
||||
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
}
|
||||
|
||||
@@ -186,12 +186,9 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
round := 1
|
||||
resolution := "hit"
|
||||
targets := []string{"Mira"}
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||
input := []dnd.CombatTurnList{
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: "first", Round: &round, Actions: []dnd.CombatAction{{Targets: targets, Resolution: &resolution}}, SourceRefs: refs}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: "first", SourceRefs: refs}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: "second"}}},
|
||||
}
|
||||
got, err := appendCombatTurnLists(input)
|
||||
@@ -201,7 +198,7 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" {
|
||||
t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns)
|
||||
}
|
||||
if got.CombatTurns[0].Round == &round || &got.CombatTurns[0].Actions[0].Targets[0] == &targets[0] || got.CombatTurns[0].Actions[0].Resolution == &resolution || &got.CombatTurns[0].SourceRefs[0] == &refs[0] {
|
||||
if &got.CombatTurns[0].SourceRefs[0] == &refs[0] {
|
||||
t.Fatal("appendCombatTurnLists() retained nested input aliases")
|
||||
}
|
||||
tests := []struct {
|
||||
|
||||
@@ -28,7 +28,6 @@ var sharedPromptPaths = map[string]string{
|
||||
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
|
||||
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
|
||||
"common-dnd-references.md": "assets/prompts/common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md": "assets/prompts/common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Report only a declaration or action and its immediate observed resolution.
|
||||
Immediate resolution may include directly associated rolls, damage, healing,
|
||||
movement, conditions, target outcomes, interruptions, or other outcomes shown
|
||||
with that declaration or action.
|
||||
|
||||
Do not follow consequences that occur on later turns or elsewhere in the
|
||||
scene.
|
||||
@@ -49,28 +49,5 @@ const (
|
||||
type CombatTurn struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||
Round *int `json:"round"`
|
||||
Actions []CombatAction `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type CombatActionCategory string
|
||||
|
||||
const (
|
||||
CombatActionCategoryAttack CombatActionCategory = "attack"
|
||||
CombatActionCategorySpell CombatActionCategory = "spell"
|
||||
CombatActionCategoryMovement CombatActionCategory = "movement"
|
||||
CombatActionCategoryItem CombatActionCategory = "item"
|
||||
CombatActionCategoryAbilityCheck CombatActionCategory = "ability_check"
|
||||
CombatActionCategorySavingThrow CombatActionCategory = "saving_throw"
|
||||
CombatActionCategoryCondition CombatActionCategory = "condition"
|
||||
CombatActionCategoryOther CombatActionCategory = "other"
|
||||
)
|
||||
|
||||
type CombatAction struct {
|
||||
Category CombatActionCategory `json:"category"`
|
||||
Declaration string `json:"declaration"`
|
||||
Targets []string `json:"targets"`
|
||||
Resolution *string `json:"resolution"`
|
||||
}
|
||||
|
||||
@@ -66,31 +66,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||
if turn.Actor != identity.NormalizeDisplay(turn.Actor) {
|
||||
issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor))
|
||||
}
|
||||
if turn.Summary != identity.NormalizeDisplay(turn.Summary) {
|
||||
issues = append(issues, prefix+".summary is not display-normalized: "+diagnostics.Quote(turn.Summary))
|
||||
}
|
||||
for actionIndex, action := range turn.Actions {
|
||||
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||
if action.Declaration != identity.NormalizeDisplay(action.Declaration) {
|
||||
issues = append(issues, actionPrefix+".declaration is not display-normalized: "+diagnostics.Quote(action.Declaration))
|
||||
}
|
||||
seenTargets := make(map[string]int, len(action.Targets))
|
||||
for targetIndex, target := range action.Targets {
|
||||
if target != identity.NormalizeDisplay(target) {
|
||||
issues = append(issues, fmt.Sprintf("%s.targets[%d] is not display-normalized: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
|
||||
}
|
||||
key := identity.ComparisonKey(target)
|
||||
if previous, exists := seenTargets[key]; exists {
|
||||
issues = append(issues, fmt.Sprintf("%s.targets[%d] duplicates target %d under comparison identity", actionPrefix, targetIndex, previous))
|
||||
} else {
|
||||
seenTargets[key] = targetIndex
|
||||
}
|
||||
}
|
||||
if action.Resolution != nil && *action.Resolution != identity.NormalizeDisplay(*action.Resolution) {
|
||||
issues = append(issues, actionPrefix+".resolution is not display-normalized: "+diagnostics.Quote(*action.Resolution))
|
||||
}
|
||||
}
|
||||
|
||||
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
|
||||
previous := turn.SourceRefs[refIndex-1]
|
||||
current := turn.SourceRefs[refIndex]
|
||||
@@ -169,12 +144,6 @@ func duplicateKey(turn dnd.CombatTurn) (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)
|
||||
|
||||
@@ -27,22 +27,6 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"},
|
||||
{name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
value.CombatTurns[0].Summary = "Aria attacks"
|
||||
}, want: "summary is not display-normalized"},
|
||||
{name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
value.CombatTurns[0].Actions[0].Declaration = "Aria attacks"
|
||||
}, want: "declaration is not display-normalized"},
|
||||
{name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"}
|
||||
}, want: "targets[0] is not display-normalized"},
|
||||
{name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"}
|
||||
}, want: "duplicates target"},
|
||||
{name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
resolution := " hit "
|
||||
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||
}, want: "resolution is not display-normalized"},
|
||||
{name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, want: "not in canonical order"},
|
||||
@@ -53,9 +37,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]}
|
||||
}, want: "out of chronological order"},
|
||||
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
duplicate := value.CombatTurns[0]
|
||||
duplicate.Summary = "different prose"
|
||||
value.CombatTurns = append(value.CombatTurns, duplicate)
|
||||
value.CombatTurns = append(value.CombatTurns, value.CombatTurns[0])
|
||||
}, want: "duplicates combat turn"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -121,11 +103,9 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
}
|
||||
|
||||
func normalizedList() dnd.CombatTurnList {
|
||||
resolution := "hit"
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1),
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -133,8 +113,6 @@ func invariantDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||
turn.SourceRefs = []source.SourceRef{ref}
|
||||
return turn
|
||||
|
||||
@@ -60,39 +60,9 @@ func issuesFor(value dnd.CombatTurnList) []string {
|
||||
if !validTurnKind(turn.TurnKind) {
|
||||
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
|
||||
}
|
||||
if turn.Round != nil && *turn.Round <= 0 {
|
||||
issues = append(issues, prefix+".round must be positive or null")
|
||||
}
|
||||
if len(turn.Actions) == 0 {
|
||||
issues = append(issues, prefix+".actions must contain at least one action")
|
||||
}
|
||||
if strings.TrimSpace(turn.Summary) == "" {
|
||||
issues = append(issues, prefix+".summary must not be empty: "+diagnostics.Quote(turn.Summary))
|
||||
}
|
||||
if len(turn.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
}
|
||||
for actionIndex, action := range turn.Actions {
|
||||
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||
if !validActionCategory(action.Category) {
|
||||
issues = append(issues, actionPrefix+".category is unsupported: "+diagnostics.Quote(string(action.Category)))
|
||||
}
|
||||
if strings.TrimSpace(action.Declaration) == "" {
|
||||
issues = append(issues, actionPrefix+".declaration must not be empty: "+diagnostics.Quote(action.Declaration))
|
||||
}
|
||||
if action.Targets == nil {
|
||||
issues = append(issues, actionPrefix+".targets must be present")
|
||||
} else {
|
||||
for targetIndex, target := range action.Targets {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
issues = append(issues, fmt.Sprintf("%s.targets[%d] must not be empty: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
|
||||
issues = append(issues, actionPrefix+".resolution must not be empty or null: "+diagnostics.Quote(*action.Resolution))
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
@@ -106,15 +76,6 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func validActionCategory(value dnd.CombatActionCategory) bool {
|
||||
switch value {
|
||||
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,7 @@ func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
|
||||
{name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"},
|
||||
{name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"},
|
||||
{name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"},
|
||||
{name: "non-positive round", mutate: func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }, want: "round must be positive"},
|
||||
{name: "missing actions", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }, want: "actions must contain"},
|
||||
{name: "empty summary", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }, want: "summary must not be empty"},
|
||||
{name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"},
|
||||
{name: "unsupported category", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unknown" }, want: "category is unsupported"},
|
||||
{name: "empty declaration", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }, want: "declaration must not be empty"},
|
||||
{name: "missing targets", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }, want: "targets must be present"},
|
||||
{name: "empty target", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }, want: "targets[0] must not be empty"},
|
||||
{name: "empty resolution", mutate: func(value *dnd.CombatTurnList) {
|
||||
resolution := " "
|
||||
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||
}, want: "resolution must not be empty"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -85,11 +74,8 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func validCombatTurnList() dnd.CombatTurnList {
|
||||
round := 2
|
||||
resolution := "The goblin is wounded."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -87,11 +87,9 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func validCombatTurnList() dnd.CombatTurnList {
|
||||
resolution := "The goblin is wounded."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package sourcerelatedness
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
@@ -50,22 +49,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for turnIndex, turn := range req.Value.CombatTurns {
|
||||
citedText := citedTexts[turnIndex]
|
||||
issues := make([]string, 0)
|
||||
if !actorAppearsInCitedText(citedText, turn.Actor) {
|
||||
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
|
||||
}
|
||||
for actionIndex, action := range turn.Actions {
|
||||
if !declarationAppearsInCitedText(citedText, action.Declaration) {
|
||||
issues = append(issues, fmt.Sprintf("action %d declaration %s was not found in cited source text", actionIndex, diagnostics.Quote(action.Declaration)))
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
if actorAppearsInCitedText(citedText, turn.Actor) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
|
||||
ReasonCode: WarningReasonCode,
|
||||
Message: diagnostics.Aggregate("combat turn not near source", issues),
|
||||
Message: diagnostics.Aggregate("combat turn not near source", []string{
|
||||
fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
@@ -74,27 +66,6 @@ func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||
return shared.ContainsTokenSequence(citedText, actor)
|
||||
}
|
||||
|
||||
func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
||||
citedTokens := tokenSet(citedText)
|
||||
for _, token := range shared.NormalizedTokens(declaration) {
|
||||
if utf8.RuneCountInString(token) >= 4 {
|
||||
if _, ok := citedTokens[token]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tokenSet(value string) map[string]struct{} {
|
||||
tokens := shared.NormalizedTokens(value)
|
||||
set := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
set[token] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) {
|
||||
resolution := "The goblin is hit."
|
||||
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActor(t *testing.T) {
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}},
|
||||
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
},
|
||||
@@ -32,31 +30,25 @@ func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
|
||||
func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) {
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
|
||||
Actions: []dnd.CombatAction{
|
||||
{Category: dnd.CombatActionCategoryOther, Declaration: "hit", Targets: []string{}, Resolution: nil},
|
||||
{Category: dnd.CombatActionCategoryOther, Declaration: "unseen monster", Targets: []string{}, Resolution: nil},
|
||||
},
|
||||
Summary: "An unrelated event.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
|
||||
}
|
||||
warning := result.Warnings[0]
|
||||
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !strings.Contains(warning.Message, "action 0") || !strings.Contains(warning.Message, "action 1") || !utf8.ValidString(warning.Message) {
|
||||
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !utf8.ValidString(warning.Message) {
|
||||
t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
|
||||
resolution := "The cart is struck."
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
|
||||
@@ -105,8 +97,7 @@ func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) {
|
||||
func validCombatTurnList() dnd.CombatTurnList {
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user