Simplify D&D combat turn extraction contract
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"Aria swings her sword","targets":["wight"],"resolution":"The wight is hit."}],"summary":"Aria attacks the wight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}
|
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ messages:
|
|||||||
content_file: ./sharedassets/common-dnd-references.md
|
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:
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
Report only a declaration or action and its immediate observed resolution.
|
|
||||||
Immediate resolution may include directly associated rolls, damage, healing,
|
|
||||||
movement, conditions, target outcomes, interruptions, or other outcomes shown
|
|
||||||
with that declaration or action.
|
|
||||||
|
|
||||||
Do not follow consequences that occur on later turns or elsewhere in the
|
|
||||||
scene.
|
|
||||||
@@ -49,28 +49,5 @@ const (
|
|||||||
type CombatTurn struct {
|
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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}},
|
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}},
|
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}},
|
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user