Close out the D&D combat turn extraction roadmap
This commit is contained in:
@@ -423,14 +423,14 @@ accepts no references. To pass an NPC result to a later spell run, bind the
|
|||||||
normalized payload explicitly at runtime; the maintained sequential example
|
normalized payload explicitly at runtime; the maintained sequential example
|
||||||
documents that operator workflow.
|
documents that operator workflow.
|
||||||
|
|
||||||
The `dnd/combat-turns` extractor and normalizer declare the same optional
|
The `dnd/combat-turns` extractor declares the optional campaign slots and the
|
||||||
campaign slots and the structured `npcs` slot. The combat extractor uses the
|
structured `npcs` slot. Campaign references guide only the LLM extraction
|
||||||
NPC payload only to ground actor and target identity; the normalizer uses its
|
stage. The deterministic normalizer declares only `npcs`, whose prepared
|
||||||
prepared immutable registry for the same canonicalization. Both slots accept
|
immutable registry supports the same actor and target canonicalization. Each
|
||||||
exactly one UTF-8 `application/json` file no larger than 1 MiB. The registry's
|
`npcs` slot accepts exactly one UTF-8 `application/json` file no larger than 1
|
||||||
source ranges remain provenance for the reference and never become combat
|
MiB. The registry's source ranges remain provenance for the reference and never
|
||||||
evidence. Binding `npcs` to extraction and normalization is stage-local, so an
|
become combat evidence. Binding `npcs` to extraction and normalization is
|
||||||
operator-driven combat run uses two explicit selectors:
|
stage-local, so an operator-driven combat run uses two explicit selectors:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
combat.extract.npcs=<npc-run>/lanes/npcs.json
|
combat.extract.npcs=<npc-run>/lanes/npcs.json
|
||||||
|
|||||||
@@ -93,13 +93,16 @@ inputs, and optionally the deprecated `roster` reference through the shared
|
|||||||
party mapping. The optional `npcs` reference is an approved normalized NPC
|
party mapping. The optional `npcs` reference is an approved normalized NPC
|
||||||
artifact used only for identity grounding; it never supplies combat evidence.
|
artifact used only for identity grounding; it never supplies combat evidence.
|
||||||
|
|
||||||
The private response shape is the same as the durable turn/action shape except
|
The private response envelope has the same fields and JSON types as the durable
|
||||||
that source references contain only `start_unit_id` and `end_unit_id`. The
|
turn/action shape except that source references contain only `start_unit_id`
|
||||||
|
and `end_unit_id`. It enforces required and nullable field presence, types, and
|
||||||
|
unknown-field rejection, while deterministic validators own enum membership,
|
||||||
|
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
|
||||||
stable-sorts turns by the earliest valid source-document position. Numeric unit
|
stable-sorts turns by the earliest valid source-document position. Numeric unit
|
||||||
IDs are identifiers; source-document slice position determines chronology.
|
IDs are identifiers; source-document slice position determines chronology.
|
||||||
Malformed candidate fields remain in the typed result for deterministic
|
Semantically malformed candidate fields remain in the typed result for the
|
||||||
validators to report.
|
configured validation and retry boundary.
|
||||||
|
|
||||||
## Deterministic candidate validation
|
## Deterministic candidate validation
|
||||||
|
|
||||||
@@ -125,10 +128,10 @@ schema, combat shape, source references, then source relatedness.
|
|||||||
## Normalization boundary
|
## Normalization boundary
|
||||||
|
|
||||||
The standalone normalizer uses key `dnd/combat-turns`, requires `merged`,
|
The standalone normalizer uses key `dnd/combat-turns`, requires `merged`,
|
||||||
provides `normalized`, accepts no options, and accepts the same optional
|
provides `normalized`, accepts no options, and accepts only the optional
|
||||||
`players`, `party`, `glossary`, deprecated `roster`, and structured `npcs`
|
structured `npcs` reference. Campaign references are LLM extraction context and
|
||||||
reference slots as extraction. The NPC registry is resolved during
|
are not normalizer inputs. The NPC registry is resolved during preparation;
|
||||||
preparation; runtime normalization uses that immutable prepared view.
|
runtime normalization uses that immutable prepared 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
|
actor, summary, declarations, targets, and non-null resolutions; canonicalizes
|
||||||
|
|||||||
@@ -104,9 +104,13 @@ The small framework registry contains only generic test schemas; production
|
|||||||
schemas remain package-owned.
|
schemas remain package-owned.
|
||||||
|
|
||||||
The spell and combat extractors' package-owned prompts declare their structured
|
The spell and combat extractors' package-owned prompts declare their structured
|
||||||
JSON inputs and private response schemas. The spell extractor's prompt declares a required
|
JSON inputs and private response schemas. The combat private schema owns the
|
||||||
`application/json` `spell_catalog` input and an optional `application/json`
|
transport envelope—required fields, JSON types, nullability, and unknown-field
|
||||||
`npcs` input. The extractor generates the catalog input from its prepared
|
rejection—while its deterministic validators own semantic constraints such as
|
||||||
|
enum membership, non-empty values and collections, and positive numbers. The
|
||||||
|
spell extractor's prompt declares a required `application/json` `spell_catalog`
|
||||||
|
input and an optional `application/json` `npcs` input. The extractor generates
|
||||||
|
the catalog input from its prepared
|
||||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||||
after the campaign reference message for both extractors. When an NPC registry
|
after the campaign reference message for both extractors. When an NPC registry
|
||||||
|
|||||||
@@ -232,12 +232,17 @@ the shared transcript, campaign-reference, and NPC-grounding 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
|
||||||
deterministic validators. Its prompt and private response schema are
|
deterministic validators. Its package-owned private response schema enforces
|
||||||
package-owned, and its prepared metadata and checkpoint fingerprints contain
|
only the structural JSON envelope; semantic artifact constraints remain with
|
||||||
|
the validator chain. Its prepared metadata and checkpoint fingerprints contain
|
||||||
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
||||||
The package exposes typed registration and is included in the production D&D
|
The package exposes typed registration and is included in the production D&D
|
||||||
registrar with the default combat extraction chain.
|
registrar with the default combat extraction chain.
|
||||||
|
|
||||||
|
The combat normalizer accepts only the optional structured NPC registry.
|
||||||
|
Campaign references remain extractor-only LLM context and are not materialized
|
||||||
|
for deterministic normalization.
|
||||||
|
|
||||||
### `internal/modules/dnd/normalize/npcs`
|
### `internal/modules/dnd/normalize/npcs`
|
||||||
|
|
||||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
The NPC normalizer performs deterministic identity-aware consolidation in
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
# D&D Combat-Turn Extraction
|
|
||||||
|
|
||||||
Status: Complete.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Add a production D&D combat-turn pipeline that converts session transcripts
|
|
||||||
into an ordered, evidence-backed account of combat activity. This is the next
|
|
||||||
recommended increment because spell and NPC extraction now provide the two
|
|
||||||
most important grounding vocabularies, while combat turns are the next useful
|
|
||||||
artifact explicitly identified by the sequential-pipeline strategy.
|
|
||||||
|
|
||||||
The feature uses the existing fixed pipeline and scene chunks for a more
|
|
||||||
structurally demanding artifact without requiring automatic workflow
|
|
||||||
composition, richer scene routing, or generic semantic deduplication.
|
|
||||||
|
|
||||||
## Target Outcome
|
|
||||||
|
|
||||||
An operator can:
|
|
||||||
|
|
||||||
1. run the NPC pipeline against a transcript;
|
|
||||||
2. run a combat-turn pipeline over the same transcript, explicitly binding the
|
|
||||||
normalized NPC artifact as a reference;
|
|
||||||
3. receive an ordered JSON list of validated combat turns and interrupting
|
|
||||||
combat events; and
|
|
||||||
4. trace every reported declaration and immediate resolution back to the
|
|
||||||
transcript units that support it.
|
|
||||||
|
|
||||||
The runs remain independent CLI invocations. Notarius does not discover prior
|
|
||||||
outputs, schedule dependent pipelines, or reconcile spell and combat artifacts
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
## Combat-Turn Artifact
|
|
||||||
|
|
||||||
The new typed artifact represents an ordered list of combat-turn records. Each
|
|
||||||
record contains:
|
|
||||||
|
|
||||||
- the canonical in-world actor;
|
|
||||||
- a turn kind distinguishing an ordinary turn from a reaction, legendary
|
|
||||||
action, lair action, or other interrupting combat event;
|
|
||||||
- the combat round as a positive integer when it is explicit or unambiguous,
|
|
||||||
and `null` otherwise;
|
|
||||||
- one or more ordered actions;
|
|
||||||
- a concise turn-level summary; and
|
|
||||||
- one or more transcript source references that collectively support every
|
|
||||||
reported field.
|
|
||||||
|
|
||||||
Each action contains a conservative category, a concise declaration, zero or
|
|
||||||
more targets, and its immediate observed resolution. Resolution is `null` when
|
|
||||||
the cited passage establishes the declaration but no immediate resolution.
|
|
||||||
Categories cover attacks, spells, movement, items, ability checks, saving
|
|
||||||
throws, condition or state changes, and an `other` fallback without requiring
|
|
||||||
the transcript to use formal rules terminology.
|
|
||||||
|
|
||||||
Reactions and similar out-of-turn events appear at the point where they occur
|
|
||||||
in transcript chronology rather than being moved to the reacting creature's
|
|
||||||
later turn. Output order is derived from cited source position; numeric source
|
|
||||||
unit IDs are identifiers, not chronology.
|
|
||||||
|
|
||||||
## Inclusion And Evidence Policy
|
|
||||||
|
|
||||||
Include a record when the transcript establishes that an in-world participant
|
|
||||||
takes a combat turn or performs a discrete interrupting combat event. Report
|
|
||||||
only declarations and their immediate resolutions, including directly
|
|
||||||
associated rolls, damage, healing, movement, conditions, or target outcomes.
|
|
||||||
For every detail reported, cite all supporting transcript units.
|
|
||||||
|
|
||||||
Exclude:
|
|
||||||
|
|
||||||
- initiative setup that contains no turn or combat event;
|
|
||||||
- tactical planning, table talk, rules lookup, and hypothetical actions;
|
|
||||||
- corrected or abandoned declarations that never become an attempted action,
|
|
||||||
except where the correction is necessary to describe the final declaration;
|
|
||||||
- recap of combat that occurred outside the current source passage; and
|
|
||||||
- downstream consequences that occur on later turns or elsewhere in the
|
|
||||||
scene.
|
|
||||||
|
|
||||||
Do not infer a round number, action-economy classification, target, roll,
|
|
||||||
amount, condition, or outcome merely from D&D rules knowledge. Preserve the
|
|
||||||
session as played, and attribute nonstandard rulings to the GM or table when
|
|
||||||
that detail is relevant to the immediate resolution.
|
|
||||||
|
|
||||||
## Identity And Reference Grounding
|
|
||||||
|
|
||||||
The extractor uses the existing D&D transcript, player, party, and glossary
|
|
||||||
prompt inputs. It also accepts the normalized NPC artifact through an optional
|
|
||||||
structured `npcs` reference slot with the same validation, size, provenance,
|
|
||||||
content-safety, and semantic-checkpoint rules used by spell extraction.
|
|
||||||
|
|
||||||
The NPC registry helps select canonical actors and targets and recognize
|
|
||||||
aliases. It does not establish that combat occurred and never becomes source
|
|
||||||
evidence. Unmatched actors and targets remain permitted because a session may
|
|
||||||
introduce combatants that were omitted from an earlier NPC run.
|
|
||||||
|
|
||||||
The deterministic normalizer also accepts the registry. Exact canonical-name
|
|
||||||
or alias matches are rewritten to the registry's canonical display name for
|
|
||||||
actors and targets; ambiguous aliases and unmatched values remain unchanged
|
|
||||||
for validation and human review. Opaque player and party references continue
|
|
||||||
to guide the LLM but are not parsed into a new roster contract in this scope.
|
|
||||||
|
|
||||||
## Extraction, Validation, And Normalization
|
|
||||||
|
|
||||||
The extractor uses one structured LLM call per supplied chunk and returns typed
|
|
||||||
combat-turn candidates. It must preserve malformed candidates for the normal
|
|
||||||
validation and retry boundary rather than silently repairing unsupported
|
|
||||||
content in mapping code.
|
|
||||||
|
|
||||||
The production default validator chain is deterministic and covers:
|
|
||||||
|
|
||||||
- required fields, arrays, nullable-round shape, and supported enum values;
|
|
||||||
- a required non-empty evidence collection, source identity, unit existence,
|
|
||||||
and range order;
|
|
||||||
- actor and declared-action relatedness to cited transcript text, expressed as
|
|
||||||
bounded warnings where deterministic substring checks are only advisory; and
|
|
||||||
- normalized identity and duplicate invariants.
|
|
||||||
|
|
||||||
Normalization is deterministic and conservative. It normalizes display
|
|
||||||
whitespace, canonicalizes exact NPC identity matches, orders and deduplicates
|
|
||||||
exact source references, and collapses only exact duplicate records with the
|
|
||||||
same normalized actor, turn kind, round value, and complete valid evidence set.
|
|
||||||
The first record is retained without synthesizing or merging prose. Every
|
|
||||||
mutation or collapse emits a scoped warning.
|
|
||||||
|
|
||||||
An LLM-backed validator and semantic reconciliation normalizer are outside the
|
|
||||||
production chain. Human evaluation owns judgments such as whether the
|
|
||||||
model grouped a long turn correctly or omitted a subtle reaction.
|
|
||||||
|
|
||||||
## Scene Strategy
|
|
||||||
|
|
||||||
The combat extractor processes every chunk delivered by the configured
|
|
||||||
chunker. Existing D&D scene annotations remain useful context, but a
|
|
||||||
`primary_mode` value does not suppress an LLM call. Avoiding a
|
|
||||||
call based on an imperfect non-combat classification could silently lose the
|
|
||||||
very turns this artifact is intended to recover.
|
|
||||||
|
|
||||||
Scene-classification and routing improvements remain separate future work. The
|
|
||||||
combat pipeline stays compatible with generic chunks that carry no D&D
|
|
||||||
annotation.
|
|
||||||
|
|
||||||
## Provenance And Checkpoints
|
|
||||||
|
|
||||||
The extractor, normalizer, and validators report stable semantic identities
|
|
||||||
through the existing manifest and prepared-component fingerprint contracts.
|
|
||||||
Prompt, private response schema, artifact policy, normalization policy, and a
|
|
||||||
bound NPC registry's semantic digest must invalidate incompatible checkpoints.
|
|
||||||
|
|
||||||
Metadata and fingerprints contain identities, counts, and digests only. They
|
|
||||||
must not contain transcript text, combat records, NPC names, reference paths,
|
|
||||||
or raw reference content. Preparation failures remain bounded and content-safe
|
|
||||||
and occur before checkpoint handlers or pipeline execution are constructed.
|
|
||||||
|
|
||||||
## Evaluation
|
|
||||||
|
|
||||||
Use human-reviewed development runs rather than exact model-output goldens.
|
|
||||||
Evaluate at least the existing transcripts used for spell and NPC development,
|
|
||||||
with separate attention to:
|
|
||||||
|
|
||||||
- combat-turn detection precision and recall;
|
|
||||||
- actor and target identity;
|
|
||||||
- turn boundaries and chronological order;
|
|
||||||
- reactions and other interrupting events;
|
|
||||||
- declaration and immediate-resolution fidelity;
|
|
||||||
- round-number restraint;
|
|
||||||
- completeness and precision of evidence; and
|
|
||||||
- duplicate behavior at chunk or scene boundaries.
|
|
||||||
|
|
||||||
Frontier and inexpensive development models may differ substantially in
|
|
||||||
semantic quality. Deterministic tests should protect structure, provenance,
|
|
||||||
identity, ordering, normalization, and orchestration rather than require exact
|
|
||||||
combat prose.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Initiative trackers, current hit points, complete encounter-state replay, or
|
|
||||||
rules-engine validation.
|
|
||||||
- Automatic comparison or reconciliation with spell artifacts.
|
|
||||||
- NPC discovery, campaign-wide entity persistence, or deterministic PC-roster
|
|
||||||
parsing.
|
|
||||||
- LLM-backed validation or generic LLM-assisted deduplication.
|
|
||||||
- Automatic pipeline scheduling, prior-output discovery, or DAG execution.
|
|
||||||
- Scene-classification changes or skipping provider calls for non-combat
|
|
||||||
chunks.
|
|
||||||
- Narrative summaries outside the immediate combat-turn scope.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- A selectable D&D combat lane produces a typed, durable JSON artifact with the
|
|
||||||
turn, action, chronology, and evidence semantics above.
|
|
||||||
- The default deterministic validator chain rejects malformed or invalidly
|
|
||||||
sourced records and emits bounded advisory relatedness warnings.
|
|
||||||
- Deterministic normalization canonicalizes exact NPC identities and collapses
|
|
||||||
only safely identical combat records while preserving chronology and
|
|
||||||
evidence.
|
|
||||||
- A normalized NPC artifact can be bound explicitly to both extraction and
|
|
||||||
normalization without becoming combat evidence.
|
|
||||||
- Semantic contracts and structured references participate in checkpoint
|
|
||||||
identity without leaking application content.
|
|
||||||
- Maintained configuration and operational examples demonstrate independent
|
|
||||||
NPC and combat invocations over the same transcript.
|
|
||||||
- Human review on representative sessions demonstrates useful turn extraction
|
|
||||||
without requiring scene-based call suppression or semantic reconciliation.
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
# D&D Combat-Turn Extraction Implementation Plan
|
|
||||||
|
|
||||||
Status: Complete.
|
|
||||||
|
|
||||||
Implement this plan in order. The feature policy and target state are defined
|
|
||||||
in [D&D Combat-Turn Extraction](dnd-combat-turn-extraction.md); this document
|
|
||||||
owns implementation sequencing and concrete technical decisions.
|
|
||||||
|
|
||||||
Do not introduce a DAG, prior-run discovery, encounter state engine, scene-based
|
|
||||||
provider-call suppression, LLM-backed validator, or semantic reconciliation.
|
|
||||||
Preserve the fixed `input -> chunk -> extract -> merge -> normalize -> output`
|
|
||||||
architecture and the existing framework retry, warning, rejection, checkpoint,
|
|
||||||
debug, and output contracts.
|
|
||||||
|
|
||||||
## Cross-Stage Decisions
|
|
||||||
|
|
||||||
### Production Identities
|
|
||||||
|
|
||||||
Use these exact identities:
|
|
||||||
|
|
||||||
- artifact kind: `dnd/combat-turn-list`;
|
|
||||||
- extractor key and normalizer key: `dnd/combat-turns`;
|
|
||||||
- extractor capability: `dnd.combat_turns`;
|
|
||||||
- prompt ID: `dnd.combat_turns`, version `v1`;
|
|
||||||
- private response-schema key: `dnd_combat_turns_llm`;
|
|
||||||
- private schema ID: `notarius.dnd.combat_turns.llm`;
|
|
||||||
- private schema name: `notarius_dnd_combat_turns_llm_v1`;
|
|
||||||
- durable schema ID: `notarius.dnd.combat_turns`;
|
|
||||||
- durable schema name: `notarius_dnd_combat_turns_v1`;
|
|
||||||
- durable schema version: `v1`; and
|
|
||||||
- durable media type: `application/json`.
|
|
||||||
|
|
||||||
The private response schema omits `source_id`; mapping code assigns the current
|
|
||||||
source identity. No combat-turn ID is added in v1.
|
|
||||||
|
|
||||||
### Canonical Go And JSON Shape
|
|
||||||
|
|
||||||
Add these types to the canonical D&D model:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type CombatTurnList struct {
|
|
||||||
CombatTurns []CombatTurn `json:"combat_turns"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CombatTurn struct {
|
|
||||||
Actor string `json:"actor"`
|
|
||||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
|
||||||
Round *int `json:"round"`
|
|
||||||
Actions []CombatAction `json:"actions"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CombatAction struct {
|
|
||||||
Category CombatActionCategory `json:"category"`
|
|
||||||
Declaration string `json:"declaration"`
|
|
||||||
Targets []string `json:"targets"`
|
|
||||||
Resolution *string `json:"resolution"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Define string types and only these lowercase durable values:
|
|
||||||
|
|
||||||
- `CombatTurnKind`: `turn`, `reaction`, `legendary_action`, `lair_action`,
|
|
||||||
`other`;
|
|
||||||
- `CombatActionCategory`: `attack`, `spell`, `movement`, `item`,
|
|
||||||
`ability_check`, `saving_throw`, `condition`, `other`.
|
|
||||||
|
|
||||||
All object fields and the top-level `combat_turns` array are required.
|
|
||||||
`combat_turns` may be empty. Every turn requires a non-empty actor, supported
|
|
||||||
turn kind, at least one action, non-empty summary, and at least one source
|
|
||||||
reference. `round` is either `null` or a positive integer. Every action requires
|
|
||||||
a supported category, non-empty declaration, and present `targets` array;
|
|
||||||
targets may be empty but may not contain empty strings. `resolution` is either
|
|
||||||
`null` or non-empty after trimming. Unknown fields are rejected at every object
|
|
||||||
level.
|
|
||||||
|
|
||||||
Strict raw decoding must distinguish a missing required `round` or `resolution`
|
|
||||||
key from an explicitly present JSON `null`, even though the accepted Go value
|
|
||||||
represents `null` with a nil pointer. Use presence-aware wire decoding or raw
|
|
||||||
key validation at codec and private-response boundaries; do not silently turn a
|
|
||||||
missing nullable field into an accepted null.
|
|
||||||
|
|
||||||
One turn-level source-reference collection collectively supports the actor,
|
|
||||||
kind, round, actions, summary, targets, and resolutions. Do not add per-action
|
|
||||||
references in v1.
|
|
||||||
|
|
||||||
### Ordering And Duplicate Identity
|
|
||||||
|
|
||||||
Source-document slice position is chronology. Numeric unit IDs are identifiers
|
|
||||||
only. An item's earliest evidence position is the minimum valid start-unit
|
|
||||||
index across all its ranges. Stable ordering puts records with valid evidence
|
|
||||||
in ascending earliest-position order, preserves encounter order for ties, and
|
|
||||||
puts records without valid evidence after valid records without reordering
|
|
||||||
them.
|
|
||||||
|
|
||||||
The normalizer may collapse two records only when all of these match:
|
|
||||||
|
|
||||||
- actor under `npcs/identity.ComparisonKey`;
|
|
||||||
- exact `turn_kind`;
|
|
||||||
- both round values, including `nil` versus a value; and
|
|
||||||
- the complete, non-empty, canonically ordered set of exact source references.
|
|
||||||
|
|
||||||
Every reference in the duplicate key must pass `source.ValidateRef` against the
|
|
||||||
current document. Invalid or empty evidence never participates in duplicate
|
|
||||||
collapse. Actions and prose do not participate in identity because two model
|
|
||||||
representations of the same cited turn should collapse; retain the complete
|
|
||||||
first record without merging prose or action lists.
|
|
||||||
|
|
||||||
### NPC Registry Contract And Prompt Reuse
|
|
||||||
|
|
||||||
Promote NPC registry preparation from the spell extractor into
|
|
||||||
`internal/modules/dnd/npcs/registry`. It owns:
|
|
||||||
|
|
||||||
- slot name `npcs` and maximum size 1,048,576 bytes;
|
|
||||||
- exact-one-item and `application/json` validation when bound;
|
|
||||||
- approved NPC codec decoding, whole-registry identity validation, canonical
|
|
||||||
re-encoding, semantic SHA-256 digest, and content-safe bounded failures;
|
|
||||||
- the exact unbound prompt value `{"npcs":[]}`;
|
|
||||||
- immutable accessors for bound state, a defensive NPC list, canonical bytes,
|
|
||||||
digest, count, and prompt input; and
|
|
||||||
- exact canonical-name/alias lookup using the NPC identity comparison policy.
|
|
||||||
|
|
||||||
Registry source references may identify another session and are not checked
|
|
||||||
against the current source. The resolver never returns content, names, aliases,
|
|
||||||
paths, or raw decoder errors in failures. Identity failures use issue codes and
|
|
||||||
record/alias indexes under the established 20-issue, 128-rune, and 4,096-byte
|
|
||||||
limits.
|
|
||||||
|
|
||||||
Move the generic bounded diagnostic helper from `dnd/npcs/diagnostics` to
|
|
||||||
`dnd/shared/diagnostics`, updating existing NPC consumers. This is an internal
|
|
||||||
package move with no diagnostic-policy change.
|
|
||||||
|
|
||||||
Add `common-dnd-npcs.md` to the shared D&D prompt assets. Its generic policy
|
|
||||||
allows exact canonical participant names and alias recognition while stating
|
|
||||||
that registry content is context, not event evidence. Both spell and combat
|
|
||||||
prompts use this exact fragment immediately after the existing shared campaign
|
|
||||||
reference message. Remove the spell-owned NPC fragment and include the shared
|
|
||||||
fragment in both prompt hashes. This intentionally changes the spell prompt
|
|
||||||
fingerprint once but must not change its request inputs, metadata shape,
|
|
||||||
registry semantics, or output contract.
|
|
||||||
|
|
||||||
Both combat extraction and normalization declare the same optional registry
|
|
||||||
slot. A bound registry contributes manifest metadata `npc_registry_digest` and
|
|
||||||
`npc_count` and a local checkpoint fingerprint `npc_registry`. An absent slot
|
|
||||||
contributes neither metadata fields nor that fingerprint.
|
|
||||||
|
|
||||||
### Validation And Diagnostics
|
|
||||||
|
|
||||||
Add deterministic validators with these exact identities:
|
|
||||||
|
|
||||||
| Validator key | Result reason | Policy fingerprint |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `extract/dnd/combat-turns/shape` | rejection `invalid_combat_turn_shape` | `dnd.combat_turns.validator.shape.v1` |
|
|
||||||
| `extract/dnd/combat-turns/source_refs` | rejection `invalid_combat_turn_source_refs` | `dnd.combat_turns.validator.source_refs.v1` |
|
|
||||||
| `extract/dnd/combat-turns/source_relatedness` | warning `combat_turn_not_near_source` | `dnd.combat_turns.validator.source_relatedness.v1` |
|
|
||||||
| `normalize/dnd/combat-turns/invariants` | rejection `invalid_combat_turn_normalization` | `dnd.combat_turns.validator.normalized.v1` |
|
|
||||||
|
|
||||||
Shape owns required arrays, enum membership, nullable fields, positive rounds,
|
|
||||||
and non-empty strings. Source-reference validation owns source identity, unit
|
|
||||||
existence, and range order through `source.ValidateRef`. Later validators defer
|
|
||||||
when shape is invalid; relatedness also defers when any source range is invalid.
|
|
||||||
|
|
||||||
Relatedness combines the turn's cited units once in document order, removing
|
|
||||||
overlap. The actor is related when its NPC comparison key is a substring of the
|
|
||||||
comparison-normalized cited text. For each action declaration, split its
|
|
||||||
comparison-normalized form on runes that are not Unicode letters or digits,
|
|
||||||
retain tokens of at least four Unicode code points, and require at least one
|
|
||||||
retained token to occur as a complete cited-text token. No retained token means
|
|
||||||
the declaration is unrelated. Emit at most one approved warning per turn,
|
|
||||||
listing whether the actor and which action indexes were not related. Do not
|
|
||||||
check targets deterministically.
|
|
||||||
|
|
||||||
The normalize invariant validator requires display-normalized strings,
|
|
||||||
comparison-unique targets within each action, canonical exact source-reference
|
|
||||||
order without duplicates, chronological record order for valid evidence, and
|
|
||||||
absence of the duplicate identity defined above. It does not require every
|
|
||||||
actor or target to exist in the optional NPC registry.
|
|
||||||
|
|
||||||
All rejection and warning messages use the shared diagnostic helper: at most 20
|
|
||||||
displayed issues, at most 128 Unicode code points per displayed value, valid
|
|
||||||
UTF-8, at most 4,096 bytes, Go quoting for control characters, and the exact
|
|
||||||
total omitted count. Validators use strict empty-options decoders and provide
|
|
||||||
one local `policy` checkpoint fingerprint with the value in the table.
|
|
||||||
|
|
||||||
### Normalization Policy
|
|
||||||
|
|
||||||
Use normalization policy `dnd.combat_turns.normalize.v1` and these warning
|
|
||||||
reason codes:
|
|
||||||
|
|
||||||
- `combat_turn_fields_normalized`;
|
|
||||||
- `combat_actor_canonicalized`;
|
|
||||||
- `combat_target_canonicalized`;
|
|
||||||
- `source_references_normalized`;
|
|
||||||
- `combat_turns_reordered`; and
|
|
||||||
- `duplicate_combat_turn_collapsed`.
|
|
||||||
|
|
||||||
Deep-clone all nested slices and pointers. Normalize actor, summary,
|
|
||||||
declarations, targets, and non-null resolutions by collapsing Unicode
|
|
||||||
whitespace through `npcs/identity.NormalizeDisplay`. Preserve `nil` resolution.
|
|
||||||
Remove comparison-duplicate targets while retaining the first display value and
|
|
||||||
target order. Do not deduplicate or reorder actions.
|
|
||||||
|
|
||||||
When a registry is bound, rewrite an actor or target only if its comparison key
|
|
||||||
matches exactly one validated canonical name or alias. Preserve unmatched
|
|
||||||
values. Registry validation makes ambiguous lookup impossible at preparation;
|
|
||||||
do not guess or perform fuzzy matching.
|
|
||||||
|
|
||||||
Sort every source-reference list by exact `source_id`, `start_unit_id`, and
|
|
||||||
`end_unit_id`, then remove exact duplicates. Stable-sort records by the
|
|
||||||
chronology rule before duplicate detection. Collapse duplicates in that order
|
|
||||||
and retain the first record unchanged after its per-record normalization.
|
|
||||||
|
|
||||||
Warning scopes use merged input indexes such as `combat_turns[3]`, even after
|
|
||||||
sorting or collapse. Emit one bounded warning for each affected record or
|
|
||||||
collapsed group; group warnings identify the retained and removed input indexes.
|
|
||||||
Only warnings from a validator-approved attempt become durable, under existing
|
|
||||||
framework policy.
|
|
||||||
|
|
||||||
The normalizer exposes manifest metadata `normalization_policy`,
|
|
||||||
`identity_policy`, and optional registry digest/count. Its checkpoint
|
|
||||||
fingerprints are `normalization_policy`, `identity_policy`, and optional
|
|
||||||
`npc_registry`; bump the normalization policy when any transformation,
|
|
||||||
ordering, or duplicate rule changes.
|
|
||||||
|
|
||||||
### Testing Rules
|
|
||||||
|
|
||||||
Follow `docs/policy/testing.md`. Tests are offline and deterministic. Use a fake
|
|
||||||
structured LLM only at the completion boundary. Protect durable shapes, domain
|
|
||||||
invariants, preparation failures, reference sensitivity, registration, and one
|
|
||||||
representative assembled workflow.
|
|
||||||
|
|
||||||
Do not add prompt-prose change detectors or tests requiring particular words or
|
|
||||||
phrases. Prompt tests may verify registration, message/input wiring, prompt and
|
|
||||||
schema identities, stable shared-fragment use, and absence of raw content from
|
|
||||||
metadata or diagnostics. Human evaluation owns semantic output quality.
|
|
||||||
|
|
||||||
## Stage 1: Shared NPC Registry And Prompt Grounding
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Create one reusable, content-safe NPC registry boundary before adding a second
|
|
||||||
consumer, while preserving spell behavior.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Add the domain registry resolver and immutable lookup described above; migrate
|
|
||||||
spell extraction from its private resolver without changing the spell
|
|
||||||
extractor's public module contract.
|
|
||||||
- Relocate bounded D&D diagnostics to the shared domain package and update all
|
|
||||||
NPC imports and tests.
|
|
||||||
- Add the shared NPC prompt fragment, move the spell prompt to it, place it
|
|
||||||
immediately after shared campaign references, and remove the spell-owned
|
|
||||||
fragment.
|
|
||||||
- Keep raw reference provenance in framework identity independently from the
|
|
||||||
semantic registry fingerprint. Preserve the exact empty prompt input and all
|
|
||||||
existing content-safe error behavior.
|
|
||||||
- Update the internal overview, module, and LLM documentation in this stage so
|
|
||||||
the implemented registry owner and shared prompt-fragment ownership remain
|
|
||||||
accurate while the later combat work remains independently scoped.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Move resolver contract tests to the domain package: absent, valid, formatted-
|
|
||||||
equivalent, malformed, unknown-field, invalid-ID, collision, media type,
|
|
||||||
item count, byte limit, defensive copies, lookup, digest, and bounded
|
|
||||||
content-free diagnostics.
|
|
||||||
- Retain spell-level tests only for spell request wiring, metadata/fingerprint
|
|
||||||
behavior, and no-regression output; remove duplicated resolver cases.
|
|
||||||
- Verify the shared prompt asset registers for spells and hashes the shared
|
|
||||||
fragment without asserting its prose.
|
|
||||||
|
|
||||||
### Completion Check
|
|
||||||
|
|
||||||
Run `go test ./internal/modules/dnd/npcs/... ./internal/modules/dnd/extract/spells ./internal/modules/dnd/validate/npcs/...`
|
|
||||||
and `git diff --check`.
|
|
||||||
|
|
||||||
## Stage 2: Combat Domain Contract And Codec
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Establish the typed artifact and durable serialization boundary without making
|
|
||||||
the combat module selectable.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Add the canonical combat types, enums, constants, and artifact kind using the
|
|
||||||
exact shape and values above.
|
|
||||||
- Add `internal/modules/dnd/codec/combatturns`, following the existing candidate
|
|
||||||
versus approved codec boundary:
|
|
||||||
- strict single-value JSON with unknown-field rejection;
|
|
||||||
- candidate encode/decode preserving validator-visible invalid values;
|
|
||||||
- approved encode/decode enforcing structural validity only;
|
|
||||||
- defensive schema and metadata values; and
|
|
||||||
- metadata containing `combat_turn_count` only.
|
|
||||||
- Add the durable v1 JSON Schema with required nullable fields, enum values,
|
|
||||||
source-reference shape, array rules, and `additionalProperties: false`.
|
|
||||||
- Add `docs/integrations/dnd-combat-turn-artifacts.md`, describing only the
|
|
||||||
implemented artifact and codec at this stage.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Test approved round trips and candidate preservation for every nullable,
|
|
||||||
enum, required-array, required-string, target, and source-reference boundary.
|
|
||||||
- Test malformed, trailing, unknown-field, and invalid structural input without
|
|
||||||
snapshotting complete errors.
|
|
||||||
- Test defensive schema/metadata copies, nil versus present-empty arrays, and
|
|
||||||
exact codec identity.
|
|
||||||
- Keep one compact durable v1 fixture for intentional compatibility coverage.
|
|
||||||
|
|
||||||
### Completion Check
|
|
||||||
|
|
||||||
Run `go test ./internal/modules/dnd/codec/combatturns` and `git diff --check`.
|
|
||||||
|
|
||||||
## Stage 3: Combat Extractor And Extraction Validators
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Implement package-complete LLM extraction and deterministic candidate
|
|
||||||
validation without production composition.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Add `internal/modules/dnd/extract/combatturns` with the exact module, prompt,
|
|
||||||
schema, capability, artifact, and reference identities above. It accepts no
|
|
||||||
options and requires `chunks` and `source.transcript`. The prompt manifest is
|
|
||||||
`dnd.combat_turns.yaml`, uses default profile `gemini-2-flash`, JSON Schema
|
|
||||||
validation, and zero provider-side repair attempts so pipeline retries remain
|
|
||||||
the only extraction retry policy.
|
|
||||||
- Declare existing optional `players`, `party`, `glossary`, and deprecated
|
|
||||||
`roster` slots plus the optional structured NPC registry slot. Reuse shared
|
|
||||||
D&D prompt inputs and the shared NPC prompt fragment.
|
|
||||||
- Embed a private response schema matching the durable shape except that source
|
|
||||||
ranges omit `source_id`. Require present arrays and nullable round/resolution
|
|
||||||
values exactly as in the durable contract.
|
|
||||||
- Prompt for the inclusion, exclusion, chronology, identity, round restraint,
|
|
||||||
and immediate-resolution policy in the feature roadmap. Instruct the model
|
|
||||||
that campaign and NPC references disambiguate identities but are never combat
|
|
||||||
evidence.
|
|
||||||
- Preserve malformed response values for validators. Canonicalize and exactly
|
|
||||||
deduplicate source ranges, assign the current source ID, and stable-sort
|
|
||||||
records by earliest valid source-document position. Do not merge records,
|
|
||||||
canonicalize NPC names deterministically, or skip any chunk in the extractor.
|
|
||||||
- Expose prompt and private-schema manifest metadata plus optional registry
|
|
||||||
digest/count. Fingerprint local names `prompt`, `response_schema`,
|
|
||||||
`mapping_policy` with value `dnd.combat_turns.extract_mapping.v1`, and optional
|
|
||||||
`npc_registry`.
|
|
||||||
- Implement shape, source-reference, and source-relatedness validators with the
|
|
||||||
exact contracts and policies above.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Through a fake LLM, test request identity, profile/session propagation,
|
|
||||||
chunk-scoped source input, campaign and NPC inputs, response mapping, source
|
|
||||||
assignment, non-monotonic-unit chronology, invalid-candidate preservation,
|
|
||||||
cancellation, and contextual provider failures.
|
|
||||||
- Test prompt/schema registration, required input wiring, metadata and
|
|
||||||
fingerprint sensitivity, defensive copies, and content redaction without
|
|
||||||
asserting prompt prose.
|
|
||||||
- Test each validator's meaningful approval, rejection, deferral, and warning
|
|
||||||
categories, including Unicode comparison, overlapping evidence, declaration
|
|
||||||
tokenization, bounded diagnostics, strict options, and typed registration.
|
|
||||||
- Verify that a bound NPC registry affects grounding metadata and checkpoint
|
|
||||||
identity but never supplies combat source references.
|
|
||||||
|
|
||||||
### Completion Check
|
|
||||||
|
|
||||||
Run `go test ./internal/modules/dnd/extract/combatturns ./internal/modules/dnd/validate/combatturns/...`
|
|
||||||
and `git diff --check`.
|
|
||||||
|
|
||||||
## Stage 4: Combat Normalization And Final Invariants
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Implement conservative identity canonicalization, chronological ordering, and
|
|
||||||
exact-evidence duplicate collapse.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Add `internal/modules/dnd/normalize/combatturns` with key
|
|
||||||
`dnd/combat-turns`, requirements `merged`, capability `normalized`, no
|
|
||||||
options, and the optional NPC registry slot.
|
|
||||||
- Resolve and retain the immutable registry during preparation. Runtime uses
|
|
||||||
the prepared view; it does not parse references repeatedly.
|
|
||||||
- Apply the exact per-record, registry, source-reference, chronology, duplicate,
|
|
||||||
warning, metadata, and fingerprint policies above. Never mutate or retain
|
|
||||||
caller-owned slices, pointers, registry data, or source data.
|
|
||||||
- Add the normalized-invariants validator. It remains deterministic, accepts no
|
|
||||||
references of its own, and defers malformed shape or invalid source evidence
|
|
||||||
to their owning validators.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Use table-driven cases for whitespace, nullable resolution, target
|
|
||||||
deduplication, actor/target registry matches, unmatched names, reference
|
|
||||||
normalization, non-monotonic source IDs, stable ties, and warning scopes.
|
|
||||||
- Cover duplicate collapse and non-collapse for every identity dimension,
|
|
||||||
especially invalid evidence, distinct ranges, different turn kinds, and
|
|
||||||
`nil` versus numbered rounds.
|
|
||||||
- Prove actions/prose come only from the first retained record, merged input is
|
|
||||||
immutable, and all nested output storage is independent.
|
|
||||||
- Test absent and bound registries, preparation failures, metadata,
|
|
||||||
fingerprints, policy sensitivity, cancellation, strict options, and module
|
|
||||||
registration.
|
|
||||||
- Test normalized-invariant rejection for each owned invariant without
|
|
||||||
duplicating shape and source-validator case matrices.
|
|
||||||
|
|
||||||
### Completion Check
|
|
||||||
|
|
||||||
Run `go test ./internal/modules/dnd/normalize/combatturns ./internal/modules/dnd/validate/combatturns/...`
|
|
||||||
and `git diff --check`.
|
|
||||||
|
|
||||||
## Stage 5: Production Composition, Sequential Workflow, And Documentation
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Make the complete combat lane selectable, verify the assembled workflow, and
|
|
||||||
document current behavior in canonical locations.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Extend the D&D registrar with the combat codec, extractor and prompt assets,
|
|
||||||
typed append-order merger, combat normalizer, typed no-op normalizer, all four
|
|
||||||
validators, and typed always-accept/always-reject variants.
|
|
||||||
- Add append behavior that preserves present-empty versus nil output and chunk
|
|
||||||
record order before normalization.
|
|
||||||
- Register the extract default chain in this order:
|
|
||||||
`generic/valid_json`, `generic/valid_json_schema`, combat shape, combat source
|
|
||||||
references, combat source relatedness.
|
|
||||||
- Register the normalize default chain in this order:
|
|
||||||
`generic/valid_json`, `generic/valid_json_schema`, combat shape, normalized
|
|
||||||
invariants, combat source references, combat source relatedness.
|
|
||||||
- Do not add a merge validator chain or change framework defaults.
|
|
||||||
- Add `examples/dnd-combat-turns.config.yml` and
|
|
||||||
`examples/dnd-npc-combat-sequential.config.yml`. Use version 3, safe relative
|
|
||||||
state paths, explicit checkpoint `enabled: false`, a generic chunker, combat
|
|
||||||
extraction with `retries: 2`, and combat normalization. Keep the dynamic NPC
|
|
||||||
output unbound in the static sequential example.
|
|
||||||
- Demonstrate runtime binding to both stage-local slots with selectors
|
|
||||||
`combat.extract.npcs=<npc-run>/lanes/npcs.json` and
|
|
||||||
`combat.normalize.npcs=<npc-run>/lanes/npcs.json`.
|
|
||||||
- Update canonical current-behavior documentation:
|
|
||||||
- Configuration owns module/validator catalogs, reference slots, limits,
|
|
||||||
default chains, and maintained examples;
|
|
||||||
- CLI owns the explicit two-selector invocation syntax;
|
|
||||||
- Operations owns the independent NPC-then-combat workflow and state
|
|
||||||
sensitivity;
|
|
||||||
- the combat integration contract owns durable fields, enum values,
|
|
||||||
evidence, normalization, warnings, and manifest metadata;
|
|
||||||
- the NPC integration contract notes combat as a consumer without redefining
|
|
||||||
combat fields;
|
|
||||||
- JSON output links the new lane payload; and
|
|
||||||
- internal overview, module, and LLM docs describe concrete packages,
|
|
||||||
preparation, prompt reuse, and fingerprints.
|
|
||||||
- At completion, mark this plan and the feature roadmap complete. Remove the
|
|
||||||
implemented combat proposal from `future.md` while retaining scene-routing,
|
|
||||||
narrative, generic deduplication, and other unimplemented work.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Extend registrar tests for production keys, typed variants, prompt assets,
|
|
||||||
default chain order, nil dependencies, and duplicate registration behavior.
|
|
||||||
- Add config/example resolution coverage for stage-local NPC bindings,
|
|
||||||
capabilities, codec compatibility, strict options, and invalid validator
|
|
||||||
placement.
|
|
||||||
- Add one assembled pipeline integration covering extract, retry-capable
|
|
||||||
validation, append merge, registry-backed normalization, final validation,
|
|
||||||
JSON output, warnings, manifest metadata, and checkpoint fingerprints using a
|
|
||||||
fake LLM.
|
|
||||||
- Add one sequential integration that materializes normalized NPC output as
|
|
||||||
both combat references and verifies canonical actor/target output, reference
|
|
||||||
provenance, and that NPC source ranges never become combat evidence.
|
|
||||||
- Add preparation-boundary coverage showing malformed or oversized NPC input
|
|
||||||
fails before checkpoint construction or pipeline execution. Do not duplicate
|
|
||||||
the shared resolver's complete malformed-input matrix.
|
|
||||||
- Validate maintained examples and documentation links through existing test
|
|
||||||
mechanisms.
|
|
||||||
|
|
||||||
### Completion Check
|
|
||||||
|
|
||||||
Run the final verification suite.
|
|
||||||
|
|
||||||
## Final Verification
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git diff --check
|
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./cmd/notarius
|
|
||||||
go test -race ./internal/modules/dnd/... ./internal/framework/pipeline \
|
|
||||||
./internal/cli ./internal/modules/integration
|
|
||||||
```
|
|
||||||
|
|
||||||
Review the final diff for:
|
|
||||||
|
|
||||||
- D&D or provider behavior leaking into generic framework packages;
|
|
||||||
- accidental scene-based call suppression or automatic pipeline composition;
|
|
||||||
- mutation or aliasing of typed artifacts, schemas, references, metadata, or
|
|
||||||
fingerprints;
|
|
||||||
- transcript, registry, prompt, schema, path, or decoder content leaking into
|
|
||||||
errors, manifests, fingerprints, or redacted summaries;
|
|
||||||
- prompt-prose change-detector tests, redundant cross-layer cases, or exact LLM
|
|
||||||
output goldens;
|
|
||||||
- current-behavior documentation claiming features before the implementing
|
|
||||||
stage lands; and
|
|
||||||
- unrelated worktree changes.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. Artifact fields and enums, nullable behavior, chronology, duplicate
|
|
||||||
identity, reference reuse, prompt composition, validator placement,
|
|
||||||
normalization, checkpoint semantics, documentation ownership, and stage
|
|
||||||
boundaries are fixed by this plan.
|
|
||||||
@@ -13,66 +13,54 @@
|
|||||||
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"actor": {
|
"actor": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"turn_kind": {
|
"turn_kind": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
|
|
||||||
},
|
},
|
||||||
"round": {
|
"round": {
|
||||||
"type": ["integer", "null"],
|
"type": ["integer", "null"]
|
||||||
"minimum": 1
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["category", "declaration", "targets", "resolution"],
|
"required": ["category", "declaration", "targets", "resolution"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"category": {
|
"category": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"]
|
|
||||||
},
|
},
|
||||||
"declaration": {
|
"declaration": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"]
|
||||||
"minLength": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["start_unit_id", "end_unit_id"],
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
},
|
},
|
||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
|
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
|
||||||
return left.StartUnitID.Int() == right.StartUnitID.Int() && left.EndUnitID.Int() == right.EndUnitID.Int()
|
return left == right
|
||||||
}
|
}
|
||||||
|
|
||||||
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
|
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
|
||||||
@@ -69,7 +69,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse)
|
|||||||
earliest := 0
|
earliest := 0
|
||||||
found := false
|
found := false
|
||||||
for _, ref := range turn.SourceRefs {
|
for _, ref := range turn.SourceRefs {
|
||||||
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()}
|
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
if err := source.ValidateRef(doc, candidate); err != nil {
|
if err := source.ValidateRef(doc, candidate); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,8 +83,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse)
|
|||||||
return earliest, found
|
return earliest, found
|
||||||
}
|
}
|
||||||
|
|
||||||
func unitSortValue(ref interface{ Int() int }) int {
|
func unitSortValue(value int) int {
|
||||||
value := ref.Int()
|
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return int(^uint(0) >> 1)
|
return int(^uint(0) >> 1)
|
||||||
}
|
}
|
||||||
@@ -121,9 +120,6 @@ func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
|
|||||||
Targets: append([]string(nil), action.Targets...),
|
Targets: append([]string(nil), action.Targets...),
|
||||||
Resolution: cloneStringPointer(action.Resolution),
|
Resolution: cloneStringPointer(action.Resolution),
|
||||||
}
|
}
|
||||||
if action.Targets != nil {
|
|
||||||
out[index].Targets = append([]string{}, action.Targets...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -134,7 +130,7 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
|
|||||||
}
|
}
|
||||||
out := make([]source.SourceRef, len(refs))
|
out := make([]source.SourceRef, len(refs))
|
||||||
for index, ref := range refs {
|
for index, ref := range refs {
|
||||||
out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()}
|
out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||||
@@ -24,20 +23,20 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Actor: "Borin", TurnKind: "turn", Round: &round,
|
Actor: "Borin", TurnKind: "turn", Round: &round,
|
||||||
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
|
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
|
||||||
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)}},
|
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Actor: "Aria", TurnKind: "reaction", Round: nil,
|
Actor: "Aria", TurnKind: "reaction", Round: nil,
|
||||||
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
|
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
|
||||||
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
|
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
|
||||||
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Actor: "Unknown", TurnKind: "other", Round: nil,
|
Actor: "Unknown", TurnKind: "other", Round: nil,
|
||||||
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
|
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
|
||||||
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromString("missing"), EndUnitID: shared.UnitRefFromString("missing")}},
|
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
|
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
|
||||||
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
|
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
|
||||||
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("not-a-unit")}},
|
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package combatturns
|
package combatturns
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
CombatTurns []combatTurnResponse `json:"combat_turns"`
|
CombatTurns []combatTurnResponse `json:"combat_turns"`
|
||||||
@@ -23,6 +27,79 @@ type combatActionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type combatSourceRefResponse struct {
|
type combatSourceRefResponse struct {
|
||||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
StartUnitID int `json:"start_unit_id"`
|
||||||
EndUnitID shared.UnitRef `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
|
||||||
}
|
}
|
||||||
|
|||||||
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal file
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) {
|
||||||
|
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","round":-1,"actions":[{"category":"unsupported","declaration":"","targets":[],"resolution":""}],"summary":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||||
|
var response extractionResponse
|
||||||
|
if err := json.Unmarshal(content, &response); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v, want semantic candidate", err)
|
||||||
|
}
|
||||||
|
turn := response.CombatTurns[0]
|
||||||
|
if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" {
|
||||||
|
t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn)
|
||||||
|
}
|
||||||
|
if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||||
|
t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", turn.SourceRefs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractionResponseDecodingDistinguishesMissingAndNullNullableFields(t *testing.T) {
|
||||||
|
validNulls := []byte(`{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[],"resolution":null}],"summary":"attacks","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||||
|
var response extractionResponse
|
||||||
|
if err := json.Unmarshal(validNulls, &response); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(nulls) error = %v", err)
|
||||||
|
}
|
||||||
|
if response.CombatTurns[0].Round != nil || response.CombatTurns[0].Actions[0].Resolution != nil {
|
||||||
|
t.Fatalf("decoded nullables = %#v, want explicit null", response.CombatTurns[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
field string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing round",
|
||||||
|
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","actions":[],"summary":"attacks","source_refs":[]}]}`,
|
||||||
|
field: "round",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing resolution",
|
||||||
|
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[]}],"summary":"attacks","source_refs":[]}]}`,
|
||||||
|
field: "resolution",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var candidate extractionResponse
|
||||||
|
err := json.Unmarshal([]byte(test.content), &candidate)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.field) {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v, want missing %s failure", err, test.field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,73 @@ func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
semanticCandidate := validCombatResponse()
|
||||||
|
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
||||||
|
turn["actor"] = ""
|
||||||
|
turn["turn_kind"] = "unsupported"
|
||||||
|
turn["round"] = -1
|
||||||
|
turn["summary"] = ""
|
||||||
|
action := turn["actions"].([]any)[0].(map[string]any)
|
||||||
|
action["category"] = "unsupported"
|
||||||
|
action["declaration"] = ""
|
||||||
|
action["targets"] = []any{""}
|
||||||
|
action["resolution"] = ""
|
||||||
|
ref := turn["source_refs"].([]any)[0].(map[string]any)
|
||||||
|
ref["start_unit_id"] = 0
|
||||||
|
ref["end_unit_id"] = -1
|
||||||
|
content, err := json.Marshal(semanticCandidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
|
||||||
|
}
|
||||||
|
turn["actions"] = []any{}
|
||||||
|
turn["source_refs"] = []any{}
|
||||||
|
content, err = json.Marshal(semanticCandidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("private schema rejected empty validator-owned collections: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(map[string]any)
|
||||||
|
}{
|
||||||
|
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }},
|
||||||
|
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }},
|
||||||
|
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
|
||||||
|
{name: "missing nullable resolution", mutate: func(turn map[string]any) {
|
||||||
|
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
candidate := validCombatResponse()
|
||||||
|
test.mutate(candidate["combat_turns"].([]any)[0].(map[string]any))
|
||||||
|
content, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err == nil {
|
||||||
|
t.Fatal("private schema accepted structurally invalid response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||||
first, err := loadResponseSchema()
|
first, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,13 +37,6 @@ const (
|
|||||||
var requiredCapabilities = []string{"merged"}
|
var requiredCapabilities = []string{"merged"}
|
||||||
var providedCapabilities = []string{"normalized"}
|
var providedCapabilities = []string{"normalized"}
|
||||||
|
|
||||||
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
|
||||||
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
|
|
||||||
Party: "Optional party roster reference material used only for disambiguation.",
|
|
||||||
Players: "Optional player list reference material used only for disambiguation.",
|
|
||||||
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil)
|
var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||||
@@ -483,39 +475,29 @@ func writeKeyInt(builder *strings.Builder, value int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
||||||
const maxDisplayedIndices = 20
|
issues := make([]string, len(removed))
|
||||||
displayed := removed
|
for index, removedIndex := range removed {
|
||||||
if len(displayed) > maxDisplayedIndices {
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
displayed = displayed[:maxDisplayedIndices]
|
|
||||||
}
|
|
||||||
indices := make([]string, len(displayed))
|
|
||||||
for index, removedIndex := range displayed {
|
|
||||||
indices[index] = strconv.Itoa(removedIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
|
|
||||||
if omitted := len(removed) - len(displayed); omitted > 0 {
|
|
||||||
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
|
|
||||||
}
|
}
|
||||||
return contracts.Warning{
|
return contracts.Warning{
|
||||||
Scope: turnScope(retainedIndex),
|
Scope: turnScope(retainedIndex),
|
||||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
Message: diagnostics.Truncate(message),
|
Message: diagnostics.Aggregate(
|
||||||
|
fmt.Sprintf("duplicate combat turn collapsed; retained input index %d", retainedIndex),
|
||||||
|
issues,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index) }
|
func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index) }
|
||||||
|
|
||||||
func referenceSlots() []contracts.ReferenceSlot {
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
return []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 and target grounding.",
|
||||||
AcceptedMediaTypes: []string{"application/json"},
|
AcceptedMediaTypes: []string{"application/json"},
|
||||||
MaxBytes: NPCRegistryMaxBytes,
|
MaxBytes: NPCRegistryMaxBytes,
|
||||||
})
|
}}
|
||||||
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
|
|
||||||
return slots
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package combatturns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -203,7 +207,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
|
|||||||
if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" {
|
if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" {
|
||||||
t.Fatalf("bound fingerprints = %#v", got)
|
t.Fatalf("bound fingerprints = %#v", got)
|
||||||
}
|
}
|
||||||
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 5 {
|
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 1 || spec.ReferenceSlots[0].Name != NPCRegistryReferenceSlot {
|
||||||
t.Fatalf("ModuleSpec() = %#v", spec)
|
t.Fatalf("ModuleSpec() = %#v", spec)
|
||||||
}
|
}
|
||||||
registry := pipeline.NewNormalizerRegistry()
|
registry := pipeline.NewNormalizerRegistry()
|
||||||
@@ -218,6 +222,26 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) {
|
||||||
|
removed := make([]int, 25)
|
||||||
|
for index := range removed {
|
||||||
|
removed[index] = math.MaxInt - index
|
||||||
|
}
|
||||||
|
warning := duplicateWarning(7, removed)
|
||||||
|
if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed {
|
||||||
|
t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning)
|
||||||
|
}
|
||||||
|
if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) {
|
||||||
|
t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(warning.Message, "5 additional issue(s) omitted") {
|
||||||
|
t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 {
|
||||||
|
t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) {
|
func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) {
|
||||||
normalizer, err := New(Options{})
|
normalizer, err := New(Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -125,6 +125,33 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCombatNormalizerRejectsCampaignReferenceBinding(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
catalog := moduleCatalog(registries)
|
||||||
|
referencePath := filepath.Join(t.TempDir(), "party.yml")
|
||||||
|
if err := os.WriteFile(referencePath, []byte("Aria: cleric\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
effective, err := combatOnlyConfig().Resolve(config.ResolveInput{
|
||||||
|
PipelineID: "dnd-combat-fixture",
|
||||||
|
Catalog: catalog,
|
||||||
|
ReferenceOverrides: []pipeline.ReferenceBinding{{
|
||||||
|
Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "party", Source: referencePath,
|
||||||
|
BindingSource: contracts.ReferenceBindingSourceCLI,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "party") {
|
||||||
|
t.Fatalf("Resolve() error = %v, want undeclared party context", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, err = pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "party") || !strings.Contains(err.Error(), "not declared") {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v, want undeclared normalize party binding", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) {
|
func TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) {
|
||||||
registries := productionNPCRegistries(t)
|
registries := productionNPCRegistries(t)
|
||||||
catalog := moduleCatalog(registries)
|
catalog := moduleCatalog(registries)
|
||||||
|
|||||||
Reference in New Issue
Block a user