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

@@ -380,8 +380,8 @@ production validators do not call the LLM and must not set `llm_profile`.
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. | | `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. |
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. | | `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. | | `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. | | `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, target, evidence-order, and chronology violations. | | `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
The production default chain for `dnd/spells` is used for both its extract and The production default chain for `dnd/spells` is used for both its extract and
normalize stages: normalize stages:
@@ -495,7 +495,7 @@ explicit ordered step.
The `dnd/combat-turns` extractor declares the optional campaign slots and the The `dnd/combat-turns` extractor declares the optional campaign slots and the
structured `npcs` slot. Campaign references guide only the LLM extraction structured `npcs` slot. Campaign references guide only the LLM extraction
stage. The deterministic normalizer declares only `npcs`, whose operation-time stage. The deterministic normalizer declares only `npcs`, whose operation-time
registry supports the same actor and target canonicalization. Each `npcs` slot registry supports the same actor canonicalization. Each `npcs` slot
accepts exactly one UTF-8 `application/json` artifact no larger than 1 MiB. The accepts exactly one UTF-8 `application/json` artifact no larger than 1 MiB. The
registry's source ranges remain provenance for the reference and never become registry's source ranges remain provenance for the reference and never become
combat evidence. An ordered step binding fans the same generated NPC artifact combat evidence. An ordered step binding fans the same generated NPC artifact

View File

@@ -25,20 +25,8 @@ Each combat turn contains these required fields:
| --- | --- | | --- | --- |
| `actor` | Non-empty string. | | `actor` | Non-empty string. |
| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. | | `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. |
| `round` | Required JSON field containing a positive integer or `null`. |
| `actions` | Required array with at least one action. |
| `summary` | Non-empty string. |
| `source_refs` | Required array with at least one source reference. | | `source_refs` | Required array with at least one source reference. |
Each action contains these required fields:
| Field | Shape |
| --- | --- |
| `category` | One of `attack`, `spell`, `movement`, `item`, `ability_check`, `saving_throw`, `condition`, or `other`. |
| `declaration` | Non-empty string describing what was declared. |
| `targets` | Required array of strings; the array may be empty, but entries may not be empty. |
| `resolution` | Required JSON field containing a non-empty string or `null`. |
Source references use the shared source-reference shape: Source references use the shared source-reference shape:
```json ```json
@@ -58,11 +46,10 @@ boundary.
The codec exposes two representations of the same typed artifact: The codec exposes two representations of the same typed artifact:
- Candidate encode/decode preserves invalid enum values, nullable values, - Candidate encode/decode preserves invalid actor and turn-kind values,
required-array presence, required strings, targets, and source references so collection presence, and source references so later validators can report
later validators can report them. Candidate decoding still requires valid them. Candidate decoding still requires valid JSON, one JSON value, known
JSON, one JSON value, known fields, and the explicitly present `round` and fields, and compatible JSON types.
`resolution` keys; `null` is distinct from a missing key.
- Approved encode/decode enforces the structural rules in this contract. - Approved encode/decode enforces the structural rules in this contract.
The codec owns the durable JSON Schema, whose object layers all set The codec owns the durable JSON Schema, whose object layers all set
@@ -96,9 +83,9 @@ An external file is validated during preparation. In an ordered pipeline, the
same slot may receive the producer's canonical generated artifact at the step same slot may receive the producer's canonical generated artifact at the step
handoff. handoff.
The private response envelope has the same fields and JSON types as the durable The private response envelope has the same turn fields and JSON types as the
turn/action shape except that source references contain only `start_unit_id` durable shape except that source references contain only `start_unit_id`
and `end_unit_id`. It enforces required and nullable field presence, types, and and `end_unit_id`. It enforces required field presence, types, and
unknown-field rejection, while deterministic validators own enum membership, unknown-field rejection, while deterministic validators own enum membership,
non-empty values and collections, and positive-number requirements. The non-empty values and collections, and positive-number requirements. The
extractor assigns the current source ID, removes exact duplicate ranges, and extractor assigns the current source ID, removes exact duplicate ranges, and
@@ -113,16 +100,14 @@ The standalone validator keys are:
| Validator | Responsibility | | Validator | Responsibility |
| --- | --- | | --- | --- |
| `extract/dnd/combat-turns/shape` | Required arrays, strings, nullable fields, positive rounds, and supported enum values. | | `extract/dnd/combat-turns/shape` | Required list, actor, turn kind, and source references, plus supported turn-kind values. |
| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. | | `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. |
| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor or declared action is not related to cited transcript text. | | `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor is not related to cited transcript text. |
Source-reference and relatedness validators defer malformed shape to the shape Source-reference and relatedness validators defer malformed shape to the shape
validator. Relatedness also defers when any cited source range is invalid. It validator. Relatedness also defers when any cited source range is invalid. It
combines overlapping cited ranges once in document order, compares actors with combines overlapping cited ranges once in document order and compares actors
the shared Unicode-aware NPC identity policy, and checks declaration tokens of with the shared Unicode-aware NPC identity policy.
at least four Unicode code points against complete cited-text tokens. Targets
are not checked deterministically.
The production D&D registrar exposes the extractor and these validators. Its The production D&D registrar exposes the extractor and these validators. Its
default extraction chain preserves this order: JSON syntax, private response default extraction chain preserves this order: JSON syntax, private response
@@ -139,14 +124,12 @@ time handoff. Runtime normalization uses that immutable prepared or handed-off
view. view.
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
actor, summary, declarations, targets, and non-null resolutions; canonicalizes the actor, canonicalizes exact registry actor matches, orders and deduplicates
exact registry actor and target matches; orders and deduplicates exact source exact source references, stable-sorts records by earliest valid source-document
references; stable-sorts records by earliest valid source-document position; and position, and collapses only records with the same actor identity, turn kind,
collapses only records with the same actor identity, turn kind, round value, and and complete valid evidence set. The first normalized record is retained.
complete valid evidence set. The first normalized record is retained without Invalid evidence is never eligible for duplicate collapse. Every mutation and
merging its actions or prose. Invalid evidence is never eligible for duplicate collapse emits a bounded warning using the merged input index in its scope.
collapse. Every mutation and collapse emits a bounded warning using the merged
input index in its scope.
The normalizer reports `normalization_policy` and `identity_policy` metadata The normalizer reports `normalization_policy` and `identity_policy` metadata
and fingerprints. An external registry may additionally contribute and fingerprints. An external registry may additionally contribute
@@ -154,8 +137,8 @@ and fingerprints. An external registry may additionally contribute
in framework handoff provenance and dependency fingerprints. The in framework handoff provenance and dependency fingerprints. The
normalized-invariants validator is normalized-invariants validator is
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference `normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
failures, then checks display normalization, target identity uniqueness, failures, then checks actor display normalization, canonical evidence ordering,
canonical evidence ordering, chronology, and duplicate identity. It rejects chronology, and duplicate identity. It rejects
with `invalid_combat_turn_normalization` under policy with `invalid_combat_turn_normalization` under policy
`dnd.combat_turns.validator.normalized.v1`. `dnd.combat_turns.validator.normalized.v1`.

View File

@@ -135,8 +135,8 @@ reusable content follows it.
Accordingly, the common prefix of all three extraction prompts is system, Accordingly, the common prefix of all three extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders the NPC registry, renders task, instructions, and transcript. Spell renders the NPC registry,
catalog, task, instructions, and transcript. Combat renders catalog, task, instructions, and transcript. Combat renders the NPC registry,
immediate resolution, NPC registry, task, instructions, and transcript. The task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system, scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its transcript, campaign-reference, task, and instruction order and marks its
transcript and campaign-reference messages ephemeral. transcript and campaign-reference messages ephemeral.

View File

@@ -265,8 +265,8 @@ source-reference validators.
### `internal/modules/dnd/extract/combatturns` ### `internal/modules/dnd/extract/combatturns`
The combat extractor prepares one structured request per supplied chunk using The combat extractor prepares one structured request per supplied chunk using
the shared extraction-evidence, identity, campaign-reference, the shared extraction-evidence, identity, campaign-reference, NPC-grounding,
immediate-resolution, NPC-grounding, and transcript prompt inputs. It and transcript prompt inputs. It
maps the private response to `dnd.CombatTurnList`, assigns the current source maps the private response to `dnd.CombatTurnList`, assigns the current source
identity, removes exact duplicate source ranges, and orders turns by valid identity, removes exact duplicate source ranges, and orders turns by valid
source-document position while preserving malformed candidate fields for source-document position while preserving malformed candidate fields for
@@ -333,8 +333,8 @@ independently for extraction and normalization.
The combat normalizer prepares an external NPC registry before execution or The combat normalizer prepares an external NPC registry before execution or
receives a generated registry at the ordered step handoff, then uses the receives a generated registry at the ordered step handoff, then uses the
immutable view during runtime. It display-normalizes combat fields, immutable view during runtime. It display-normalizes actors,
rewrites exact canonical-name or alias matches for actors and targets, orders rewrites exact canonical-name or alias matches for actors, orders
and deduplicates source references, stable-sorts records by source-document and deduplicates source references, stable-sorts records by source-document
position, and collapses only exact duplicate identities with fully valid position, and collapses only exact duplicate identities with fully valid
evidence. It deep-clones output storage and emits bounded warnings scoped to evidence. It deep-clones output storage and emits bounded warnings scoped to
@@ -414,16 +414,14 @@ production chains.
## D&D Combat Validators ## D&D Combat Validators
Combat shape validation owns required arrays, strings, nullable values, positive Combat shape validation owns the required list, actor, supported turn kind, and
rounds, and supported enums. Combat source-reference validation defers invalid non-empty source-reference collection. Combat source-reference validation defers invalid
shape, checks source identity, unit existence, and range order, and reports all shape, checks source identity, unit existence, and range order, and reports all
defects through bounded aggregates. Combat source-relatedness defers invalid defects through bounded aggregates. Combat source-relatedness defers invalid
shape or ranges, uses the shared traversal to combine overlapping cited units shape or ranges, uses the shared traversal to combine overlapping cited units
in document order, and emits at most one bounded advisory warning per turn for in document order, and emits at most one bounded advisory warning per turn for
unrelated actors or declaration text. an unrelated actor. Actors use normalized consecutive-token matching. The
Actors use normalized consecutive-token matching; declarations retain the normalized-invariants validator owns actor display normalization, canonical
minimum four-rune token heuristic. The normalized-invariants
validator owns display normalization, comparison-unique targets, canonical
source-reference order, chronology, and exact duplicate identity; it defers source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and shape and source-reference failures. All four validators are deterministic and
expose local policy fingerprints. The D&D registrar orders them after generic expose local policy fingerprints. The D&D registrar orders them after generic

View File

@@ -87,7 +87,7 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. | | `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. | | `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. | | `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, and combat-action artifact types. | | `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, and combat-turn artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. | | `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. | | `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. | | `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |

View File

@@ -10,7 +10,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], "required": ["actor", "turn_kind", "source_refs"],
"properties": { "properties": {
"actor": { "actor": {
"type": "string", "type": "string",
@@ -20,44 +20,6 @@
"type": "string", "type": "string",
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"] "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": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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 // DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it. Nullable round and resolution fields are // validators have approved it.
// decoded through raw values so a missing key is not confused with null.
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) { func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
decoder := json.NewDecoder(bytes.NewReader(content)) decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
var wire combatTurnListWire var value dnd.CombatTurnList
if err := decoder.Decode(&wire); err != nil { if err := decoder.Decode(&value); err != nil {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err) return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
} }
var trailing any var trailing any
if err := decoder.Decode(&trailing); err != io.EOF { if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values") 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 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 { func validate(value dnd.CombatTurnList) error {
if value.CombatTurns == nil { if value.CombatTurns == nil {
return fmt.Errorf("combat_turns must be present") return fmt.Errorf("combat_turns must be present")
@@ -192,38 +105,9 @@ func validate(value dnd.CombatTurnList) error {
if !validTurnKind(turn.TurnKind) { if !validTurnKind(turn.TurnKind) {
return fmt.Errorf("%s.turn_kind must be supported", prefix) 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 { if len(turn.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) 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 { for refIndex, ref := range turn.SourceRefs {
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
@@ -248,12 +132,3 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
return false 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 { func validList() dnd.CombatTurnList {
round := 1
resolution := "The wight is hit."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
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.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
} }
@@ -35,30 +24,26 @@ func validList() dnd.CombatTurnList {
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json") raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json")
if err != nil { if err != nil {
t.Fatalf("read durable fixture: %v", err) t.Fatal(err)
} }
codec := New() codec := New()
value, err := codec.Decode(raw) value, err := codec.Decode(raw)
if err != nil { if err != nil {
t.Fatalf("Decode() error = %v, want nil", err) t.Fatalf("Decode() error = %v", err)
} }
if want := validList(); !reflect.DeepEqual(value, want) { if want := validList(); !reflect.DeepEqual(value, want) {
t.Fatalf("Decode() = %#v, want %#v", value, want) t.Fatalf("Decode() = %#v, want %#v", value, want)
} }
encoded, err := codec.Encode(value) encoded, err := codec.Encode(value)
if err != nil { if err != nil {
t.Fatalf("Encode() error = %v, want nil", err) t.Fatalf("Encode() error = %v", err)
} }
var compact bytes.Buffer var compact bytes.Buffer
if err := json.Compact(&compact, raw); err != nil { if err := json.Compact(&compact, raw); err != nil {
t.Fatalf("compact durable fixture: %v", err) t.Fatal(err)
} }
if !bytes.Equal(encoded, compact.Bytes()) { if !bytes.Equal(encoded, compact.Bytes()) {
t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes()) t.Fatalf("Encode() = %s, want %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)
} }
} }
@@ -69,16 +54,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
} }
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { 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() registry := pipeline.NewArtifactCodecRegistry()
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v", err) t.Fatal(err)
} }
spec, ok := registry.Spec(dnd.CombatTurnListKind) spec, ok := registry.Spec(dnd.CombatTurnListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
@@ -86,176 +66,59 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
} }
} }
func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) { func TestCodecStrictlyRejectsMalformedUnknownAndInvalidJSON(t *testing.T) {
codec := New() validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
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, raw, want string }{
tests := []struct { {"malformed", `{`, "decode dnd combat turn list"},
name string {"unknown top-level", `{"combat_turns":[],"unexpected":true}`, "unknown field"},
raw string {"unknown turn field", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"turn","unexpected":true`, 1), "unknown field"},
want string {"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"},
{name: "malformed", raw: `{`, want: "decode dnd combat turn list"}, {"missing list", `{}`, "combat_turns must be present"},
{name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"}, {"unsupported kind", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), "turn_kind must be supported"},
{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"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) t.Fatalf("Decode() error = %v, want %q", err, test.want)
} }
}) })
} }
} }
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *testing.T) {
round := -1 candidates := []dnd.CombatTurnList{
resolution := " " {},
candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ {CombatTurns: []dnd.CombatTurn{}},
Actor: " ", {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: nil}}},
TurnKind: "unsupported", {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{}}}},
Round: &round, {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
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)
} }
decoded, err := New().DecodeCandidate(content) for _, candidate := range candidates {
if err != nil || !reflect.DeepEqual(decoded, candidate) { content, err := New().EncodeCandidate(candidate)
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, 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) { func TestCodecRejectsRequiredShapeAndReferenceBoundaries(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) {
tests := []struct { tests := []struct {
name string name string
value dnd.CombatTurnList value dnd.CombatTurnList
want string want string
}{ }{
{name: "nil combat turns", value: dnd.CombatTurnList{}, want: "combat_turns must be present"}, {"nil combat turns", dnd.CombatTurnList{}, "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"}, {"empty actor", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].Actor = " " }), "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"}, {"unsupported turn kind", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].TurnKind = "unsupported" }), "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"}, {"nil source refs", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs = nil }), "source_refs must contain"},
{name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"}, {"empty source ID", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"}, {"non-positive start", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
{name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"}, {"non-positive end", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
{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"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -270,14 +133,13 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
codec := New() codec := New()
first := codec.Schema() first := codec.Schema()
first.JSONSchema[0] = '[' first.JSONSchema[0] = '['
second := codec.Schema() if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
t.Fatal("Schema() returned shared bytes") t.Fatal("Schema() returned shared bytes")
} }
metadata := codec.Metadata(validList()) metadata := codec.Metadata(validList())
metadata["other"] = true metadata["other"] = true
if next := codec.Metadata(validList()); len(next) != 1 || next["combat_turn_count"] != 1 { 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 content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control: cache_control:

View File

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

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

View File

@@ -10,7 +10,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], "required": ["actor", "turn_kind", "source_refs"],
"properties": { "properties": {
"actor": { "actor": {
"type": "string" "type": "string"
@@ -18,37 +18,6 @@
"turn_kind": { "turn_kind": {
"type": "string" "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": { "source_refs": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -96,9 +96,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
turns[index] = dnd.CombatTurn{ turns[index] = dnd.CombatTurn{
Actor: turn.Actor, Actor: turn.Actor,
TurnKind: dnd.CombatTurnKind(turn.TurnKind), TurnKind: dnd.CombatTurnKind(turn.TurnKind),
Round: cloneIntPointer(turn.Round),
Actions: canonicalActions(turn.Actions),
Summary: turn.Summary,
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID), SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
} }
} }
@@ -108,22 +105,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
return dnd.CombatTurnList{CombatTurns: turns} 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 { func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil { if refs == nil {
return nil return nil
@@ -134,19 +115,3 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
} }
return out 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 := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{ slots = append(slots, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot, 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"}, AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes, MaxBytes: NPCRegistryMaxBytes,

View File

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

View File

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

View File

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

View File

@@ -22,9 +22,7 @@ const (
normalizationPolicy = "dnd.combat_turns.normalize.v1" normalizationPolicy = "dnd.combat_turns.normalize.v1"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
ReasonCodeActorCanonicalized = "combat_actor_canonicalized" ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeTurnsReordered = "combat_turns_reordered" ReasonCodeTurnsReordered = "combat_turns_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed" ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
@@ -122,11 +120,9 @@ type normalizedRecord struct {
hasEvidence bool hasEvidence bool
} }
type targetCanonicalization struct { type actorCanonicalization struct {
actionIndex int from string
targetIndex int to string
from string
to string
} }
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) { 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)) records := make([]normalizedRecord, len(input.CombatTurns))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputTurn := range input.CombatTurns { 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) earliest, hasEvidence := earliestSourcePosition(doc, turn)
records[index] = normalizedRecord{ records[index] = normalizedRecord{
turn: turn, turn: turn,
@@ -145,13 +141,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, 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 { if actorChange != nil {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: turnScope(index), 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)), 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 { if refsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: turnScope(index), Scope: turnScope(index),
@@ -205,87 +185,27 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
return dnd.CombatTurnList{CombatTurns: output}, warnings 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 := cloneCombatTurn(input)
output.Actor = identity.NormalizeDisplay(input.Actor) output.Actor = identity.NormalizeDisplay(input.Actor)
output.Summary = identity.NormalizeDisplay(input.Summary)
var actorChange *targetCanonicalization
if canonical, ok := registry.Lookup(output.Actor); ok { if canonical, ok := registry.Lookup(output.Actor); ok {
canonicalName := identity.NormalizeDisplay(canonical.Name) canonicalName := identity.NormalizeDisplay(canonical.Name)
if output.Actor != canonicalName { output.Actor = canonicalName
actorChange = &targetCanonicalization{from: output.Actor, to: 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) canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
output.SourceRefs = canonicalRefs output.SourceRefs = canonicalRefs
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs) refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, fieldsChanged, actorChange, targetChanges, refsChanged return output, actorChange, refsChanged
} }
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn { func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
output := input 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 { if input.SourceRefs != nil {
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
copy(output.SourceRefs, input.SourceRefs) copy(output.SourceRefs, input.SourceRefs)
@@ -293,38 +213,6 @@ func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
return output 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 { func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) { if (left == nil) != (right == nil) || len(left) != len(right) {
return false return false
@@ -454,12 +342,6 @@ func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool
var key strings.Builder var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor)) writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind)) 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 { for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID) writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.StartUnitID)
@@ -499,7 +381,7 @@ func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index)
func referenceSlots() []contracts.ReferenceSlot { func referenceSlots() []contracts.ReferenceSlot {
return []contracts.ReferenceSlot{{ return []contracts.ReferenceSlot{{
Name: NPCRegistryReferenceSlot, 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"}, AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes, MaxBytes: NPCRegistryMaxBytes,

View File

@@ -24,17 +24,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
resolution := " the target is hit "
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: " aria ", Actor: " aria ",
TurnKind: dnd.CombatTurnKindTurn, 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 ",
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, 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]) original := cloneCombatTurn(input.CombatTurns[0])
@@ -46,17 +38,14 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v", err) 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" { if got := result.Value.CombatTurns[0]; got.Actor != "Aria" {
t.Fatalf("normalized turn = %#v, want display-normalized fields", got) t.Fatalf("normalized turn = %#v, want canonical actor", 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)
} }
wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}} 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) { if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
t.Fatalf("normalized refs = %#v, want %#v", 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) { if !hasWarningReason(result.Warnings, reason) {
t.Fatalf("warnings = %#v, missing reason %q", 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) { if !reflect.DeepEqual(input.CombatTurns[0], original) {
t.Fatalf("Normalize() mutated input: got %#v, want %#v", 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" result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" { if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized targets share input storage") t.Fatal("normalized source refs share input storage")
} }
} }
@@ -79,8 +68,6 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "aria", Actor: "aria",
TurnKind: dnd.CombatTurnKindTurn, 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}}, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
}}} }}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
@@ -92,8 +79,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("Normalize() error = %v", err) t.Fatalf("Normalize() error = %v", err)
} }
turn := result.Value.CombatTurns[0] turn := result.Value.CombatTurns[0]
if turn.Actor != "Aria" || turn.Actions[0].Targets[0] != "Goblin" { if turn.Actor != "Aria" {
t.Fatalf("operation-normalized turn = %#v, want Aria/Goblin", turn) t.Fatalf("operation-normalized turn = %#v, want Aria", turn)
} }
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { 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) 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) { func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} 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 := 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 := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
second.Summary = "later record"
duplicate := cloneCombatTurn(first) 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}) invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}} 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 { if len(result.Value.CombatTurns) != 3 {
t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns)) 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) t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns)
} }
if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) { if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) {
@@ -164,14 +147,12 @@ func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) { func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
doc := testDocument() doc := testDocument()
base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}) base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
base.Round = nil
tests := []struct { tests := []struct {
name string name string
other dnd.CombatTurn other dnd.CombatTurn
}{ }{
{name: "different actor", other: withActor(base, "Borin")}, {name: "different actor", other: withActor(base, "Borin")},
{name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)}, {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: "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})}, {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{ return dnd.CombatTurn{
Actor: actor, Actor: actor,
TurnKind: dnd.CombatTurnKindTurn, 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}, SourceRefs: []source.SourceRef{ref},
} }
} }
@@ -329,28 +307,17 @@ func hasWarningReason(warnings []contracts.Warning, reason string) bool {
return false return false
} }
func intPointer(value int) *int { return &value }
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn { func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
turn.Actor = actor turn.Actor = actor
turn.Actions = cloneCombatTurn(turn).Actions
return turn return turn
} }
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn { func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
turn.TurnKind = kind 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 return turn
} }
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
turn.SourceRefs = []source.SourceRef{ref} turn.SourceRefs = []source.SourceRef{ref}
turn.Actions = cloneCombatTurn(turn).Actions
return turn return turn
} }

View File

@@ -59,23 +59,6 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value 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 { if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
} }

View File

@@ -186,12 +186,9 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
} }
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
round := 1
resolution := "hit"
targets := []string{"Mira"}
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.CombatTurnList{ 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"}}}, {CombatTurns: []dnd.CombatTurn{{Actor: "second"}}},
} }
got, err := appendCombatTurnLists(input) 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" { 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) 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") t.Fatal("appendCombatTurnLists() retained nested input aliases")
} }
tests := []struct { 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-identity.md": "assets/prompts/common-dnd-identity.md",
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md", "common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
"common-dnd-references.md": "assets/prompts/common-dnd-references.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", "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 { type CombatTurn struct {
Actor string `json:"actor"` Actor string `json:"actor"`
TurnKind CombatTurnKind `json:"turn_kind"` TurnKind CombatTurnKind `json:"turn_kind"`
Round *int `json:"round"`
Actions []CombatAction `json:"actions"`
Summary string `json:"summary"`
SourceRefs []source.SourceRef `json:"source_refs"` 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) { if turn.Actor != identity.NormalizeDisplay(turn.Actor) {
issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(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++ { for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
previous := turn.SourceRefs[refIndex-1] previous := turn.SourceRefs[refIndex-1]
current := turn.SourceRefs[refIndex] current := turn.SourceRefs[refIndex]
@@ -169,12 +144,6 @@ func duplicateKey(turn dnd.CombatTurn) (string, bool) {
var key strings.Builder var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor)) writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind)) 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 { for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID) writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.StartUnitID)

View File

@@ -27,22 +27,6 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
want string 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: "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) { {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}} value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, want: "not in canonical order"}, }, 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]} value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]}
}, want: "out of chronological order"}, }, want: "out of chronological order"},
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { {name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
duplicate := value.CombatTurns[0] value.CombatTurns = append(value.CombatTurns, value.CombatTurns[0])
duplicate.Summary = "different prose"
value.CombatTurns = append(value.CombatTurns, duplicate)
}, want: "duplicates combat turn"}, }, want: "duplicates combat turn"},
} }
for _, test := range tests { for _, test := range tests {
@@ -121,11 +103,9 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
} }
func normalizedList() dnd.CombatTurnList { func normalizedList() dnd.CombatTurnList {
resolution := "hit"
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1), Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
Summary: "Aria attacks", 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}}} 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 { func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
turn.SourceRefs = []source.SourceRef{ref} turn.SourceRefs = []source.SourceRef{ref}
return turn return turn

View File

@@ -60,39 +60,9 @@ func issuesFor(value dnd.CombatTurnList) []string {
if !validTurnKind(turn.TurnKind) { if !validTurnKind(turn.TurnKind) {
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(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 { if len(turn.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must contain at least one reference") 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 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 { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} 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: "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: "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: "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: "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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -85,11 +74,8 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
} }
func validCombatTurnList() dnd.CombatTurnList { func validCombatTurnList() dnd.CombatTurnList {
round := 2
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -87,11 +87,9 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
} }
func validCombatTurnList() dnd.CombatTurnList { func validCombatTurnList() dnd.CombatTurnList {
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -3,7 +3,6 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "fmt"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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) warnings := make([]contracts.Warning, 0)
for turnIndex, turn := range req.Value.CombatTurns { for turnIndex, turn := range req.Value.CombatTurns {
citedText := citedTexts[turnIndex] citedText := citedTexts[turnIndex]
issues := make([]string, 0) if actorAppearsInCitedText(citedText, turn.Actor) {
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 {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex), Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
ReasonCode: WarningReasonCode, 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 return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
@@ -74,27 +66,6 @@ func actorAppearsInCitedText(citedText string, actor string) bool {
return shared.ContainsTokenSequence(citedText, actor) 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 { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -12,12 +12,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) { func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActor(t *testing.T) {
resolution := "The goblin is hit."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn, Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, 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{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction, Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
Actions: []dnd.CombatAction{ SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
{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}},
}}} }}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err) t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
} }
warning := result.Warnings[0] 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) t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning)
} }
} }
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) { func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
resolution := "The cart is struck."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn, Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "The cart attacks.", 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."}}} 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}) 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 { func validCombatTurnList() dnd.CombatTurnList {
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -48,10 +48,10 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
} }
client := &fakeCombatLLMClient{responses: []string{ client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"), combatTestInvalidEnumResponse("unsupported"),
combatTestTurnResponse("mira thorn", "turn", "watches", "mira thorn", 1, 1), combatTestTurnResponse("mira thorn", "turn", 1),
combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2), combatTestTurnResponse("Mira Thorn", "reaction", 2),
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "mira thorn", 3, 3), combatTestTurnResponse("Hooded Guard", "turn", 3),
}} }}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}) prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil { if err != nil {
@@ -86,8 +86,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
if err != nil { if err != nil {
t.Fatalf("Decode(combat output) error = %v", err) 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" { 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 and targets", value) t.Fatalf("normalized combat output = %#v, want ordered canonical actors", value)
} }
for _, turn := range value.CombatTurns { for _, turn := range value.CombatTurns {
for _, ref := range turn.SourceRefs { 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) { if !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) {
t.Fatalf("warnings = %#v, want relatedness and registry normalization warnings", output.Warnings) t.Fatalf("warnings = %#v, want registry normalization warning", output.Warnings)
} }
if len(output.Manifest.References) != 2 { if len(output.Manifest.References) != 2 {
t.Fatalf("manifest references = %#v, want separate extract and normalize provenance", output.Manifest.References) 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) t.Fatalf("Resolve() error = %v, want nil", err)
} }
client := &fakeCombatLLMClient{responses: []string{ client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"), combatTestInvalidEnumResponse("unsupported"),
combatTestInvalidEnumResponse("turn", "unsupported"), combatTestInvalidEnumResponse("invalid"),
combatTestInvalidEnumResponse("unsupported", "unsupported"), combatTestInvalidEnumResponse("unknown"),
}} }}
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: client}) prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil { 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 return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil
} }
func combatTestInvalidEnumResponse(turnKind, category string) string { func combatTestInvalidEnumResponse(turnKind 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) 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 { func combatTestTurnResponse(actor, turnKind string, 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) 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 { func hasCombatWarning(warnings []contracts.Warning, reason string) bool {

View File

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