Simplify D&D combat turn extraction contract

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

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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}]}]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
Description: "Optional normalized NPC registry used for canonical actor grounding.",
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,

View File

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

View File

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

View File

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

View File

@@ -46,13 +46,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
turn["actor"] = ""
turn["turn_kind"] = "unsupported"
turn["round"] = -1
turn["summary"] = ""
action := turn["actions"].([]any)[0].(map[string]any)
action["category"] = "unsupported"
action["declaration"] = ""
action["targets"] = []any{""}
action["resolution"] = ""
ref := turn["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0
ref["end_unit_id"] = -1
@@ -63,7 +56,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
}
turn["actions"] = []any{}
turn["source_refs"] = []any{}
content, err = json.Marshal(semanticCandidate)
if err != nil {
@@ -83,12 +75,10 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
name string
mutate func(map[string]any)
}{
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }},
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }},
{name: "missing actor", mutate: func(turn map[string]any) { delete(turn, "actor") }},
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
{name: "missing nullable resolution", mutate: func(turn map[string]any) {
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
}},
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := validCombatResponse()
@@ -134,11 +124,8 @@ func validCombatResponse() map[string]any {
return map[string]any{
"combat_turns": []any{
map[string]any{
"actor": "Aria", "turn_kind": "reaction", "round": nil,
"actions": []any{map[string]any{
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
}},
"summary": "Aria reacts.",
"actor": "Aria",
"turn_kind": "reaction",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
},
},

View File

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

View File

@@ -22,9 +22,7 @@ const (
normalizationPolicy = "dnd.combat_turns.normalize.v1"
NormalizationPolicy = normalizationPolicy
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeTurnsReordered = "combat_turns_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
@@ -122,11 +120,9 @@ type normalizedRecord struct {
hasEvidence bool
}
type targetCanonicalization struct {
actionIndex int
targetIndex int
from string
to string
type actorCanonicalization struct {
from string
to string
}
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
@@ -137,7 +133,7 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
records := make([]normalizedRecord, len(input.CombatTurns))
warnings := make([]contracts.Warning, 0)
for index, inputTurn := range input.CombatTurns {
turn, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry)
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
earliest, hasEvidence := earliestSourcePosition(doc, turn)
records[index] = normalizedRecord{
turn: turn,
@@ -145,13 +141,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
earliest: earliest,
hasEvidence: hasEvidence,
}
if fieldsChanged {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
ReasonCode: ReasonCodeFieldsNormalized,
Message: fmt.Sprintf("input index %d: combat turn fields normalized", index),
})
}
if actorChange != nil {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
@@ -160,15 +149,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)),
})
}
for _, targetChange := range targetChanges {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
ReasonCode: ReasonCodeTargetCanonicalized,
Message: fmt.Sprintf("input index %d: action %d target %d canonicalized from %s to %s",
index, targetChange.actionIndex, targetChange.targetIndex,
diagnostics.Quote(targetChange.from), diagnostics.Quote(targetChange.to)),
})
}
if refsChanged {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
@@ -205,87 +185,27 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
return dnd.CombatTurnList{CombatTurns: output}, warnings
}
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, bool, *targetCanonicalization, []targetCanonicalization, bool) {
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
output := cloneCombatTurn(input)
output.Actor = identity.NormalizeDisplay(input.Actor)
output.Summary = identity.NormalizeDisplay(input.Summary)
var actorChange *targetCanonicalization
if canonical, ok := registry.Lookup(output.Actor); ok {
canonicalName := identity.NormalizeDisplay(canonical.Name)
if output.Actor != canonicalName {
actorChange = &targetCanonicalization{from: output.Actor, to: canonicalName}
output.Actor = canonicalName
}
output.Actor = canonicalName
}
var actorChange *actorCanonicalization
if input.Actor != output.Actor {
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
}
targetChanges := make([]targetCanonicalization, 0)
for actionIndex := range output.Actions {
action := &output.Actions[actionIndex]
action.Declaration = identity.NormalizeDisplay(action.Declaration)
if action.Resolution != nil {
resolution := identity.NormalizeDisplay(*action.Resolution)
action.Resolution = &resolution
}
if action.Targets == nil {
continue
}
targets := make([]string, 0, len(action.Targets))
seen := make(map[string]struct{}, len(action.Targets))
for targetIndex, target := range action.Targets {
normalized := identity.NormalizeDisplay(target)
if canonical, ok := registry.Lookup(normalized); ok {
canonicalName := identity.NormalizeDisplay(canonical.Name)
if normalized != canonicalName {
targetChanges = append(targetChanges, targetCanonicalization{
actionIndex: actionIndex,
targetIndex: targetIndex,
from: normalized,
to: canonicalName,
})
}
normalized = canonicalName
}
key := identity.ComparisonKey(normalized)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
targets = append(targets, normalized)
}
action.Targets = targets
}
fieldsChanged := input.Actor != output.Actor || input.Summary != output.Summary
if len(input.Actions) != len(output.Actions) {
fieldsChanged = true
}
for index := range output.Actions {
if input.Actions[index].Declaration != output.Actions[index].Declaration ||
!stringSlicesEqual(input.Actions[index].Targets, output.Actions[index].Targets) ||
!stringPointersEqual(input.Actions[index].Resolution, output.Actions[index].Resolution) {
fieldsChanged = true
break
}
}
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
output.SourceRefs = canonicalRefs
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, fieldsChanged, actorChange, targetChanges, refsChanged
return output, actorChange, refsChanged
}
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
output := input
if input.Round != nil {
round := *input.Round
output.Round = &round
}
if input.Actions != nil {
output.Actions = make([]dnd.CombatAction, len(input.Actions))
for index, action := range input.Actions {
output.Actions[index] = cloneCombatAction(action)
}
}
if input.SourceRefs != nil {
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
copy(output.SourceRefs, input.SourceRefs)
@@ -293,38 +213,6 @@ func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
return output
}
func cloneCombatAction(input dnd.CombatAction) dnd.CombatAction {
output := input
if input.Targets != nil {
output.Targets = make([]string, len(input.Targets))
copy(output.Targets, input.Targets)
}
if input.Resolution != nil {
resolution := *input.Resolution
output.Resolution = &resolution
}
return output
}
func stringSlicesEqual(left, right []string) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func stringPointersEqual(left, right *string) bool {
if (left == nil) != (right == nil) {
return false
}
return left == nil || *left == *right
}
func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
@@ -454,12 +342,6 @@ func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool
var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind))
if turn.Round == nil {
key.WriteByte('0')
} else {
key.WriteByte('1')
writeKeyInt(&key, *turn.Round)
}
for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
@@ -499,7 +381,7 @@ func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index)
func referenceSlots() []contracts.ReferenceSlot {
return []contracts.ReferenceSlot{{
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
Description: "Optional normalized NPC registry used for canonical actor grounding.",
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,

View File

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

View File

@@ -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...)
}

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,10 +48,10 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
}
client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"),
combatTestTurnResponse("mira thorn", "turn", "watches", "mira thorn", 1, 1),
combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2),
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "mira thorn", 3, 3),
combatTestInvalidEnumResponse("unsupported"),
combatTestTurnResponse("mira thorn", "turn", 1),
combatTestTurnResponse("Mira Thorn", "reaction", 2),
combatTestTurnResponse("Hooded Guard", "turn", 3),
}}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
@@ -86,8 +86,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
if err != nil {
t.Fatalf("Decode(combat output) error = %v", err)
}
if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[0].Actions[0].Targets[0] != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" {
t.Fatalf("normalized combat output = %#v, want ordered canonical actors and targets", value)
if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" {
t.Fatalf("normalized combat output = %#v, want ordered canonical actors", value)
}
for _, turn := range value.CombatTurns {
for _, ref := range turn.SourceRefs {
@@ -96,8 +96,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
}
}
}
if !hasCombatWarning(output.Warnings, "combat_turn_not_near_source") || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeTargetCanonicalized) {
t.Fatalf("warnings = %#v, want relatedness and registry normalization warnings", output.Warnings)
if !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) {
t.Fatalf("warnings = %#v, want registry normalization warning", output.Warnings)
}
if len(output.Manifest.References) != 2 {
t.Fatalf("manifest references = %#v, want separate extract and normalize provenance", output.Manifest.References)
@@ -140,9 +140,9 @@ func TestProductionCombatPipelineAttributesExhaustedInvalidEnumsToShapeValidatio
t.Fatalf("Resolve() error = %v, want nil", err)
}
client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"),
combatTestInvalidEnumResponse("turn", "unsupported"),
combatTestInvalidEnumResponse("unsupported", "unsupported"),
combatTestInvalidEnumResponse("unsupported"),
combatTestInvalidEnumResponse("invalid"),
combatTestInvalidEnumResponse("unknown"),
}}
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
@@ -312,12 +312,12 @@ func (client *fakeCombatLLMClient) CompleteStructured(ctx context.Context, req c
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil
}
func combatTestInvalidEnumResponse(turnKind, category string) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"round":1,"actions":[{"category":%q,"declaration":"watches","targets":["Mira"],"resolution":null}],"summary":"invalid candidate","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind, category)
func combatTestInvalidEnumResponse(turnKind string) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind)
}
func combatTestTurnResponse(actor, turnKind, declaration, target string, round, unit int) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"round":%d,"actions":[{"category":"attack","declaration":%q,"targets":[%q],"resolution":"observed"}],"summary":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, round, declaration, target, declaration, unit, unit)
func combatTestTurnResponse(actor, turnKind string, unit int) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, unit, unit)
}
func hasCombatWarning(warnings []contracts.Warning, reason string) bool {

View File

@@ -120,8 +120,8 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
combatValue = decoded
}
}
if len(combatValue.CombatTurns) != 1 || combatValue.CombatTurns[0].Actor != "Mira Thorn" || combatValue.CombatTurns[0].Actions[0].Targets[0] != "Hooded Guard" {
t.Fatalf("combat output = %#v, want registry-normalized actor and target", combatValue)
if len(combatValue.CombatTurns) != 1 || combatValue.CombatTurns[0].Actor != "Mira Thorn" {
t.Fatalf("combat output = %#v, want registry-normalized actor", combatValue)
}
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs)
}
@@ -179,9 +179,8 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ
}}}
case combatextract.PromptID:
payload = map[string]any{"combat_turns": []any{map[string]any{
"actor": "Mira Thorn", "turn_kind": "turn", "round": 1,
"actions": []any{map[string]any{"category": "attack", "declaration": "watches", "targets": []string{"Hooded Guard"}, "resolution": "observed"}},
"summary": "Mira Thorn watches the gate.",
"actor": "Mira Thorn",
"turn_kind": "turn",
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}}
default: