From 61016671ab0c34e617fcaf2343c0d82a449f3326 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 23 Jul 2026 13:18:38 +0000 Subject: [PATCH] Add D&D NPC interaction artifact codec --- docs/roadmap/dnd-npc-interactions.md | 197 ++++++ docs/roadmap/future.md | 26 +- docs/roadmap/implementation.md | 570 ++++++++++++++++++ .../schemas/dnd_npc_interactions.v1.json | 50 ++ .../dnd/codec/npcinteractions/codec.go | 140 +++++ .../dnd/codec/npcinteractions/codec_test.go | 183 ++++++ .../testdata/dnd_npc_interactions.v1.json | 18 + internal/modules/dnd/types.go | 23 + 8 files changed, 1185 insertions(+), 22 deletions(-) create mode 100644 docs/roadmap/dnd-npc-interactions.md create mode 100644 docs/roadmap/implementation.md create mode 100644 internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json create mode 100644 internal/modules/dnd/codec/npcinteractions/codec.go create mode 100644 internal/modules/dnd/codec/npcinteractions/codec_test.go create mode 100644 internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json diff --git a/docs/roadmap/dnd-npc-interactions.md b/docs/roadmap/dnd-npc-interactions.md new file mode 100644 index 0000000..008f828 --- /dev/null +++ b/docs/roadmap/dnd-npc-interactions.md @@ -0,0 +1,197 @@ +# D&D NPC Interactions + +Status: Accepted + +## Purpose + +The normalized NPC registry intentionally answers only who was identified in a +session. It does not answer whether an NPC was merely mentioned, participated +in dialogue, or fought alongside or against the party. Add a separate, +ordered NPC-interaction artifact for that occurrence-level information rather +than expanding the identity registry. + +This feature preserves the minimal-extractor policy: the model identifies one +bounded kind of interaction and its supporting transcript evidence. It does +not summarize the interaction, infer relationships, or maintain NPC state. + +## Desired End State + +A D&D pipeline can run NPC extraction first and supply its accepted normalized +registry to a later `dnd/npc-interactions` lane. The later lane emits an ordered +list of evidenced interaction occurrences involving registry NPCs. + +The production identities should be: + +- extractor and normalizer key: `dnd/npc-interactions`; +- artifact kind: `dnd/npc-interaction-list`; +- durable schema ID: `notarius.dnd.npc_interactions`; +- durable schema name: `notarius_dnd_npc_interactions_v1`; +- durable schema version: `v1`; and +- media type: `application/json`. + +The lane must use the existing D&D module organization, shared prompt assets, +typed artifact pipeline, codec boundary, registration pattern, and default +validator composition. + +## Artifact Contract + +The durable payload is an object containing an `interactions` array. The array +may be empty. Each interaction contains exactly: + +- `name`: the canonical NPC name from the supplied registry; +- `kind`: one value from the bounded interaction vocabulary; and +- `source_refs`: one or more current-transcript ranges supporting both the NPC + identity and the classified interaction. + +Every object rejects unknown fields. The model-facing response should contain +only the corresponding name, kind, and source-unit range candidates. Notarius +attaches the current source identity deterministically; the model must not +reproduce it. + +Do not add a separate interaction ID in the durable contract. Stable ordering, +the canonical NPC name, the bounded kind, and exact evidence ranges are enough +to identify and audit an occurrence for the present use cases. Revisit durable +cross-artifact identity only with a concrete consumer requirement. + +## Interaction Vocabulary + +Use this closed vocabulary: + +| Kind | Meaning | +| --- | --- | +| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. | +| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. | +| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. | +| `combat_ally` | The NPC actively participates in combat on the party's side. | +| `combat_opponent` | The NPC actively participates in combat against the party. | +| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. | + +`other` is a residual category for positively evidenced activity, not an +escape hatch for uncertain classification. Omit a candidate when the +transcript does not support one category. + +When activities overlap within one occurrence, apply this precedence: + +1. active combat participation outranks dialogue, presence, and mention; +2. dialogue outranks non-combat presence and mention; +3. non-combat presence outranks mention; and +4. `other` applies only when none of the defined categories describes the + evidenced activity. + +Combat alignment is not resolved by precedence. An NPC cannot be both a combat +ally and combat opponent in one occurrence; split the record when its alignment +meaningfully changes. + +## Occurrence Boundaries And Ordering + +An occurrence represents one NPC, one interaction kind, and one locally +coherent transcript passage. Combine repeated evidence only while it supports +the same uninterrupted activity. Create separate occurrences when: + +- the interaction kind changes; +- combat alignment changes; +- a scene or meaningful absence separates repeated activity; or +- the NPC is first mentioned and later becomes present. + +Extraction remains chunk-scoped, so an occurrence must not span accepted chunk +boundaries. Merge and normalization must not semantically combine occurrences +from different chunks. + +Order the durable list by the earliest valid source position. Use canonical +name, interaction kind, and canonical source ranges as deterministic +tie-breakers. Remove only exact duplicate records; do not use model judgment to +collapse nearby occurrences. + +## NPC Grounding And Evidence + +The extractor declares the existing `npcs` reference slot and requires a bound, +accepted NPC registry. The normal same-run configuration binds the normalized +output of an earlier NPC lane through an ordered generated reference. Existing +framework support for a compatible external registry may remain available, but +must not weaken the artifact contract. + +Present the registry's names-only projection to the model. An emitted name must +match one canonical registry name exactly after the registry's established +lookup rules are applied, and the durable artifact retains that canonical +display name. Do not copy NPC IDs or registry source references into an +interaction. + +The registry establishes available identity, not occurrence. Every interaction +must cite current transcript ranges that independently support both the NPC and +the selected kind. Campaign references and generated artifacts may disambiguate +a name, but they never become interaction evidence. + +If no registry NPC has an evidenced interaction, the correct artifact is an +empty list. A missing, rejected, or incompatible required registry handoff is a +pipeline dependency failure rather than a request to extract ungrounded names. + +## Prompt And Model Boundary + +Follow the established D&D prompt ordering and cache-boundary policy. Stable +shared instructions, lane instructions, campaign references, and the NPC +names-only projection precede the variable transcript. Factor wording shared +with the spell and combat lanes into the existing shared asset pattern rather +than creating nearly identical package-local messages. + +The prompt must: + +- define the closed vocabulary and precedence rules; +- ask for occurrences involving only supplied registry NPCs; +- distinguish mention from presence and dialogue; +- require current-transcript evidence for identity and kind; +- forbid summaries, relationship inference, sentiment, aliases, and invented + names; and +- permit an empty result. + +Keep the private JSON Schema structural. Deterministic code owns canonical-name +resolution, enum enforcement at the durable boundary, source-range validation, +ordering, exact deduplication, and evidence invariants. + +## Validation And Quality + +Provide production validators and default chains at extraction and +normalization boundaries consistent with the existing D&D artifacts. The +append-only merge does not require a separate default validator chain. +Deterministic validation must reject: + +- missing or extra fields; +- empty or unrecognized NPC names; +- names absent from the supplied registry; +- unknown interaction kinds; +- empty, malformed, out-of-source, or reversed evidence ranges; and +- records whose evidence comes from a reference rather than the current + transcript. + +Any relatedness validator should remain warning-only unless evaluation +demonstrates a reliable deterministic rejection rule. Diagnostics must be +bounded and must not leak reference contents. + +Evaluate the lane on a small human-reviewed transcript set that includes every +category, transitions between categories, multiple occurrences for one NPC, +mentions followed by appearances, alignment changes, repeated evidence across +chunks, and empty output. Review category agreement, evidence sufficiency, +ordering, duplicate behavior, and reliability on the smaller models the +application is intended to support. Treat model-output evaluation as a human +development aid, not a brittle deterministic test oracle. + +## Documentation Outcomes + +When implemented, document the durable artifact in `docs/integrations/`, add +the selectable module and validator contracts to configuration documentation, +update the current module and pipeline internals, and provide a maintained +ordered-pipeline example showing NPC extraction followed by interaction +extraction. Future behavior must remain in this roadmap until it exists. + +## Non-Goals + +This scope does not: + +- add occurrence fields to the normalized NPC registry; +- summarize dialogue, combat, or NPC behavior; +- infer disposition, relationships, factions, motives, or persistent state; +- identify player characters or anonymous groups as NPCs; +- add scene participants or duplicate scene-description responsibilities; +- reconcile NPC aliases or perform LLM-assisted semantic deduplication; +- derive interaction records from registry evidence; or +- introduce a DAG, concurrent cross-lane reconciliation, or a new reference + mechanism. diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index a2bbe46..992b1a3 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -18,28 +18,10 @@ not as committed release dates. ### Extract NPC Interactions -- Add an ordered NPC-interaction artifact that records how an identifiable NPC - participates in the session without adding occurrence-level state to the - normalized NPC registry. Use `npc-interactions` as the working lane and - product name; the exact artifact kind may be finalized with its contract. -- Keep each record minimal: canonical NPC `name`, one bounded interaction - `kind`, and transcript `source_refs` supporting both the identity and - classification. -- Start with the mutually exclusive vocabulary `mentioned`, - `noncombat_presence`, `dialogue`, `combat_ally`, `combat_opponent`, and - `other`. Define narrow inclusion rules and category precedence before - implementation so overlapping activity does not produce arbitrary labels. -- Model interactions as ordered occurrences rather than one scalar NPC - category. The same NPC may therefore have separate records when the - transcript establishes distinct interactions, such as dialogue followed by - hostile combat. -- Run NPC identity extraction first and provide its accepted names-only - projection to the interaction extractor for grounding. Registry names may - disambiguate identity but never establish that an interaction occurred, and - registry source references must not be copied into interaction evidence. -- Evaluate category agreement, evidence sufficiency, duplicate behavior, and - smaller-model reliability on human-reviewed transcripts before expanding the - enum or adding additional fields. +Add a minimal, ordered `dnd/npc-interactions` artifact after NPC identity +extraction. The proposed contract, occurrence semantics, category policy, +grounding rules, evidence requirements, and non-goals are defined in +[D&D NPC Interactions](dnd-npc-interactions.md). ### Export Accepted Chunk Maps diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md new file mode 100644 index 0000000..e6d80b5 --- /dev/null +++ b/docs/roadmap/implementation.md @@ -0,0 +1,570 @@ +# D&D NPC Interactions Implementation Plan + +Status: Ready for implementation + +## Objective + +Implement the accepted [D&D NPC Interactions](dnd-npc-interactions.md) +roadmap as a production D&D artifact lane. The finished lane must extract an +ordered list of minimal, evidence-grounded NPC interaction occurrences from +each accepted transcript chunk, ground every NPC against a required accepted +NPC registry, and preserve the existing fixed pipeline and generated-reference +architecture. + +This plan is the implementation authority for sequencing and file-level work. +The feature roadmap remains authoritative for product intent, category +semantics, occurrence boundaries, evidence policy, and non-goals. Follow +[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md) +throughout: do not add descriptive, analytical, relationship, or state fields. + +## Fixed Decisions + +The implementation must use these identities: + +| Concern | Identity | +| --- | --- | +| Extractor key | `dnd/npc-interactions` | +| Normalizer key | `dnd/npc-interactions` | +| Artifact kind | `dnd/npc-interaction-list` | +| Durable schema ID | `notarius.dnd.npc_interactions` | +| Durable schema name | `notarius_dnd_npc_interactions_v1` | +| Durable schema version | `v1` | +| Durable media type | `application/json` | +| Private prompt ID | `dnd.npc_interactions` | +| Private response schema ID | `notarius.dnd.npc_interactions.llm` | +| Reference slot | `npcs` | + +Use a top-level `interactions` array. Each durable record has exactly `name`, +`kind`, and `source_refs`. Do not add an interaction ID. The closed kind +vocabulary is `mentioned`, `noncombat_presence`, `dialogue`, `combat_ally`, +`combat_opponent`, and `other`. + +The `npcs` slot is required for both extraction and normalization. It accepts +only the existing `dnd/npc-list` JSON artifact within the existing NPC registry +size limit. A bound registry with an empty `npcs` array is valid and requires an +empty interaction artifact. An absent binding, malformed registry, failed +generated handoff, or unknown interaction name is not permission to perform +ungrounded extraction. + +Use the existing `internal/modules/dnd/npcs/registry.Resolver` for construction- +time external references and operation-time generated references. The +extractor presents only `Registry.PromptInput()` to the model. Name lookup uses +the registry's established comparison-key policy before normalization; the +normalizer replaces a recognized variant with the exact canonical registry +display name. Registry source references and campaign references never become +interaction evidence. + +An occurrence is one NPC, one kind, and one coherent passage within one +accepted chunk. The model must split a category or combat-alignment transition. +No occurrence spans chunks. Merge preserves chunk order. Normalization: + +1. replaces recognized names with canonical registry display names; +2. sorts and exact-deduplicates each record's source references; +3. orders records by earliest valid source position, then canonical name, + interaction kind, and the canonical source-reference sequence; +4. removes records only when canonical name, kind, and the complete canonical + source-reference sequence are all identical; and +5. never semantically combines nearby, overlapping, or cross-chunk records. + +The private model response remains structural and contains name, kind, and +integer start/end unit candidates. Mapping attaches the current source ID. +Invalid strings, kinds, and ranges must survive mapping far enough for the +deterministic validator that owns the rule to reject them. + +For the final record comparator, use source-document unit position for the +earliest valid reference. Compare canonical names first by the existing NPC +identity comparison key and then by exact display string; compare kinds by +their string values; and compare canonical reference sequences +lexicographically by `source_id`, start-unit document position, and end-unit +document position. Records with valid evidence precede records without it; +normalized output validation rejects the latter before durable encoding. + +No framework change, new identity subsystem, DAG behavior, LLM-assisted +deduplication, or scene dependency is part of this plan. + +## Testing And Documentation Rules + +Apply [Testing Policy](../policy/testing.md) at every stage: + +- Protect artifact round trips, required fields, enum values, source + provenance, registry grounding, deterministic ordering, exact + deduplication, reference immutability, and generated handoff behavior. +- Use the real codec, registry, resolver, normalizer, and pipeline machinery + when they are fast and deterministic. Stub only the structured LLM boundary. +- Keep provider calls offline and deterministic. +- Test each policy at its narrowest stable owner. Do not repeat every codec, + validator, and normalizer case in an end-to-end test. +- Do not add a prompt-cache “change detector” based on exact message count, + shared-prefix length, token count, or prompt hash. Reuse the shared prompt + assets, test rendered behavior and manifest composition, and document the + ordering policy instead. +- Do not use broad golden snapshots for prompts or diagnostics. A maintained + durable JSON fixture is appropriate because the complete serialized artifact + is an intentional integration contract. + +Until the final stage, keep unimplemented behavior only in roadmap +documentation. Update current-behavior documentation and examples in the same +stage that makes the production lane selectable. + +## Stage 1 — Add The Typed Artifact And Durable Codec + +### Goal + +Establish the domain type and strict serialized boundary without changing the +production module catalog. + +### Changes + +1. Extend `internal/modules/dnd/types.go` with: + - `NPCInteractionListKind`; + - a string-backed `NPCInteractionKind`; + - exported constants for all six accepted values; + - `NPCInteractionList` with `Interactions []NPCInteraction`; and + - `NPCInteraction` with `Name string`, `Kind NPCInteractionKind`, and + `SourceRefs []source.SourceRef`. +2. Add `internal/modules/dnd/codec/npcinteractions` following the existing NPC + and combat-turn codec boundary: + - expose the fixed schema constants and `MediaType`; + - implement the typed artifact codec for `dnd.NPCInteractionList`; + - keep `EncodeCandidate` and `DecodeCandidate` strict about one JSON value + and unknown fields while preserving typed semantic candidates; + - make approved `Encode` and `Decode` enforce non-empty names, the closed + kind enum, at least one structurally valid source reference, and the + durable schema shape; and + - report only `interaction_count` in codec metadata. +3. Add + `internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json`. + It must require the top-level array and all three record fields, reject + unknown fields at every object level, encode the six-value enum, require at + least one source reference, and use the established durable source-reference + shape. +4. Add a small maintained fixture under + `internal/modules/dnd/codec/npcinteractions/testdata/` containing at least + two ordered records with distinct kinds and source ranges. + +### Tests + +At the codec package boundary, cover: + +- fixture decode/encode round trip and canonical compact JSON; +- exact kind, schema identity, version, media type, and Go type registration; +- empty-list support and nil-versus-present-empty behavior where the existing + codecs distinguish it; +- strict rejection of malformed JSON, trailing values, and unknown fields; +- approved-boundary rejection of each meaningful required-field, enum, and + source-reference violation; +- candidate-boundary preservation of semantic values for later validators; and +- defensive copies for schema bytes and metadata. + +Do not duplicate source-document existence checks in the codec; those belong to +the source-reference validator. + +### Completion Criteria + +- The new types and codec compile and pass focused tests. +- The codec can be registered into an isolated artifact codec registry with + exact type `dnd.NPCInteractionList`. +- No production registrar or configuration catalog exposes the new kind yet. + +## Stage 2 — Implement The Chunk-Scoped Extractor And Prompt + +### Goal + +Add an independently testable LLM-backed extractor that requires NPC grounding, +maps only minimal private output, and follows the established D&D prompt-cache +layout. + +### Changes + +1. Add `internal/modules/dnd/extract/npcinteractions` using the current D&D + extractor organization: + - `assets.go`; + - `canonicalize.go`; + - `extractor.go`; + - `model.go`; + - `schema.go`; + - `scriptorium_assets.go`; + - corresponding focused tests; and + - package-local embedded prompt and schema assets. +2. Give the extractor strict empty options, required capabilities `chunks` and + `source.transcript`, provided capability `dnd.npc_interactions`, and artifact + kind `dnd.NPCInteractionListKind`. +3. Build its reference slots from `shared.ReferenceSlots(...)`, then append the + `npcs` slot with: + - `Required: true`; + - accepted media type `application/json`; + - accepted artifact kind `dnd.NPCListKind`; + - the existing registry maximum byte count; and + - a description stating that the accepted registry is required identity + grounding, not evidence. + Return defensive, consistently sorted slot declarations from both + `ModuleSpec()` and the constructed extractor. +4. Construct an `npcregistry.Resolver` from the preparation-time reference set. + Resolve operation references inside `Extract`, require `Registry.Bound()`, + and fail before the LLM call if the required registry is absent or invalid. + Preserve the resolver's content-safe error behavior. +5. Use `shared.ChunkPromptMaterial` and `shared.PromptInputs`. Replace the + ordinary `npcs` input with the resolved names-only `Registry.PromptInput()`. + Do not place full registry bytes, NPC IDs, registry evidence, or provenance + in prompt inputs. +6. Add these assets: + - `assets/prompts/dnd.npc_interactions.yaml`; + - `assets/prompts/task.md`; + - `assets/prompts/instructions.md`; and + - `assets/schemas/dnd_npc_interactions_llm.v1.json`. +7. Use the existing shared prompt assets in this exact semantic order: + common system; common extraction evidence; common identity; common campaign + references; common NPC grounding; lane task; lane instructions; variable + transcript. Preserve the established cache-control placement used by the + spell and combat-turn manifests. +8. Revise the shared `common-dnd-npcs.md` wording once so it truthfully applies + to all three consumers: a normalized registry is always presented to the + prompt and may be empty. Keep its identity, context-only rule, and + prohibition on treating registry provenance as event evidence. Do not fork a + nearly identical interaction-specific grounding asset. +9. The lane prompt must state the category definitions, precedence, occurrence + splitting rules, registry-only name restriction, empty-output behavior, and + exclusions from the feature roadmap. Keep the response schema structural: + it requires the envelope and fields but leaves enum membership, non-empty + values, and source semantics to deterministic validators. +10. Map private records to `dnd.NPCInteractionList`, attach the current source + ID, sort and exact-deduplicate ranges within each candidate, and stable-sort + candidates with valid evidence by earliest source-document position. + Preserve invalid candidate fields rather than repairing or dropping them. +11. Expose prompt, response-schema, and mapping-policy identities through + manifest metadata and checkpoint fingerprints, following the current D&D + extractors. Include the NPC names-only projection digest in local checkpoint + identity. For a preparation-time external registry, expose only bounded + digest/count metadata; do not place names or source content in metadata. + Generated-reference provenance remains framework-owned. + +### Tests + +At stable package boundaries, cover: + +- nil/cancelled/invalid extraction requests and provider failures; +- required-slot declaration and rejection of an unbound or malformed registry + before any LLM call; +- operation-time generated registry resolution by an extractor prepared + without generated bytes; +- exact names-only registry prompt input and separation from transcript + evidence; +- empty registry plus empty response; +- mapping of every category, current-source attachment, evidence + canonicalization, and source-position ordering; +- preservation of invalid names, kinds, and ranges for validators; +- strict private JSON shape and unknown-field rejection; +- prompt rendering with required inputs, category policy, shared assets, and + transcript-last ordering; +- prompt/schema registration and content-safe metadata/fingerprints; and +- defensive module specifications and strict rejection of unknown options. + +Run the existing spell and combat-turn prompt/asset tests after changing the +shared NPC prompt fragment. Do not assert an exact shared-prefix length. + +### Completion Criteria + +- The extractor is constructible and testable through the typed extractor + contract. +- A valid request makes one scheduled structured-completion call and returns a + typed candidate with current-source provenance. +- Missing required NPC grounding cannot reach the model. +- The extractor remains unregistered in the production D&D family until later + stages provide the rest of the lane. + +## Stage 3 — Add Deterministic Artifact Validators + +### Goal + +Give each semantic invariant one clear validation owner and make invalid model +output a rejection rather than a normalization repair or framework error. + +### Changes + +Add these typed validator packages, each with strict empty options, bounded +diagnostics, typed registration, and the fixed artifact kind: + +1. `internal/modules/dnd/validate/npcinteractions/shape` + (`extract/dnd/npc-interactions/shape`): + - require a non-empty trimmed name without mutating it; + - require one of the six kinds; and + - require at least one source-reference candidate with structurally required + values. +2. `internal/modules/dnd/validate/npcinteractions/registry` + (`extract/dnd/npc-interactions/registry`): + - use `npcregistry.Resolver` at construction and operation time; + - require a bound registry; + - approve a name when `Registry.Lookup` recognizes it under the established + comparison policy; + - reject unknown names in stable record order; and + - never use registry evidence as transcript evidence. +3. `internal/modules/dnd/validate/npcinteractions/source_refs` + (`extract/dnd/npc-interactions/source_refs`): + - require the current source identity; + - require referenced units to exist; and + - require valid inclusive start/end order through the current document. + Follow the established D&D citation helpers and rejection aggregation + limits rather than introducing a generic framework dependency on the + interaction type. +4. `internal/modules/dnd/validate/npcinteractions/source_relatedness` + (`extract/dnd/npc-interactions/source_relatedness`): + - inspect only current transcript text covered by the cited ranges; + - emit at most one bounded warning per record when the NPC name cannot be + related to that evidence; + - do not claim to validate category agreement; and + - ignore campaign and registry content for relatedness. +5. `internal/modules/dnd/validate/npcinteractions/invariants` + (`normalize/dnd/npc-interactions/invariants`): + - require the exact canonical display name returned by registry lookup; + - require canonical source-reference order with no duplicate ranges; + - require the complete list order defined in Fixed Decisions; and + - reject exact duplicate normalized records. + +The registry and invariants validators must support operation-time generated +references. Construction-time metadata and fingerprints follow the resolver +pattern used by registry-aware modules and must not include NPC names or +content. + +### Tests + +Give each policy one primary test owner: + +- shape tests own empty names, enum membership, and missing evidence; +- registry tests own required binding, comparison-key recognition, unknown + names, operation-time generated overrides, empty registries, and content-safe + failures; +- source-reference tests own wrong source IDs, missing units, reversed ranges, + and valid multi-range records; +- relatedness tests own current-transcript-only warnings and bounded + diagnostics; and +- invariant tests own exact canonical names, final ordering, canonical evidence + sequences, and duplicate rejection. + +Also verify that validators do not mutate artifacts, source documents, or +registries and that their specs register only for +`dnd.NPCInteractionListKind`. + +### Completion Criteria + +- Valid typed candidates are approved and malformed or ungrounded candidates + are rejected at the intended boundary. +- No validator silently canonicalizes, drops, or merges records. +- References can explain identity but cannot satisfy source evidence checks. + +## Stage 4 — Add Deterministic Merge And Normalization + +### Goal + +Preserve chunk-scoped occurrences through merge, then produce the canonical +ordered artifact without semantic inference. + +### Changes + +1. Add `internal/modules/dnd/normalize/npcinteractions` with strict empty + options and the fixed module key and artifact kind. +2. Declare the same required `npcs` reference slot as the extractor. Construct + and resolve `npcregistry.Resolver` at the same preparation/operation + boundaries, and require a bound registry. +3. Implement the normalization algorithm in Fixed Decisions: + - clone all nested values; + - normalize display whitespace only as needed for lookup; + - replace every recognized name with the registry's exact canonical display + name; + - canonicalize each reference list without merging overlapping ranges; + - compute order from source-document positions rather than assuming numeric + unit IDs are contiguous; + - apply every specified tie-breaker; and + - collapse only exact records after canonicalization. +4. Preserve nil versus present-empty list behavior consistently with the other + D&D normalizers. +5. Emit bounded warnings for visible canonical-name changes, reference + canonicalization, record reordering, and exact duplicate removal. Use stable + reason codes and the shared D&D diagnostics helpers. Never include reference + content in a warning. +6. Add an append-only merger function for `dnd.NPCInteractionList` beside the + other D&D typed append functions in + `internal/modules/dnd/register/merge.go`. It must preserve accepted + chunk/lane order, preserve present-empty semantics, and deep-clone source + reference slices so outputs do not alias inputs. Do not register it until + Stage 5. +7. Add normalization-policy and NPC projection identities to checkpoint + fingerprints. Manifest metadata may include the policy identity and bounded + external-registry digest/count, but not names or source content. + +### Tests + +Cover: + +- canonical registry name replacement, including Unicode/case/spacing lookup; +- required and operation-time generated registry behavior; +- source-reference ordering and exact range deduplication; +- ordering by real document position with all deterministic tie-breakers; +- separation of category transitions, alignment transitions, nearby records, + overlapping-but-nonidentical evidence, and nonidentical records from + different chunks; +- collapse of exact duplicates only; +- stable, bounded warnings; +- nil/present-empty behavior; +- input ownership and nested-slice cloning; and +- append-order merger behavior independently of normalization. + +Use table-driven pure normalization cases for the dense ordering and duplicate +rules, and package-level normalizer tests for resolver and warning behavior. + +### Completion Criteria + +- Normalization is deterministic and idempotent. +- Running normalization twice does not change the value or emit new + transformation warnings on the second pass. +- Semantically distinct occurrences remain distinct. +- The module and merger compile but are not yet selectable through production + composition. + +## Stage 5 — Compose The Complete Production Lane + +### Goal + +Register the complete typed lane atomically so configuration cannot select a +partial implementation. + +### Changes + +Update the D&D registrar: + +1. In `internal/modules/dnd/register/modules.go`, register: + - the NPC-interaction codec; + - extractor and prompt assets; + - the append-order merger variant for + `dnd.NPCInteractionListKind`; + - the `dnd/npc-interactions` normalizer; and + - the generic no-op normalizer variant for the new exact Go type. +2. In `internal/modules/dnd/register/validators.go`, register all five domain + validators plus generic always-accept and always-reject typed variants for + the new artifact type. +3. In `internal/modules/dnd/register/chains.go`, register: + - extraction chain: + `generic/valid_json`, + `extract/dnd/npc-interactions/shape`, + `extract/dnd/npc-interactions/registry`, + `extract/dnd/npc-interactions/source_refs`, + `generic/valid_json_schema`, + `extract/dnd/npc-interactions/source_relatedness`; + - normalization chain: + `generic/valid_json`, + `extract/dnd/npc-interactions/shape`, + `extract/dnd/npc-interactions/registry`, + `normalize/dnd/npc-interactions/invariants`, + `extract/dnd/npc-interactions/source_refs`, + `generic/valid_json_schema`, + `extract/dnd/npc-interactions/source_relatedness`. +4. Do not add a default merge validator chain. The append merger performs no + semantic mutation, and extraction plus normalized boundaries own the + consequential policies. +5. Extend registrar contract tests to verify exact typed coverage, keys, + artifact kinds, prompt/schema assets, reference-slot agreement, and default + validator order. + +### Tests + +- Extend the existing D&D family registration test rather than creating + parallel catalog snapshots. +- Verify that the extractor and normalizer both expose required compatible + `npcs` slots and that generated `dnd/npc-list` bindings resolve only from an + earlier step. +- Verify production resolution rejects a missing required binding, a later or + same-step producer, a wrong artifact kind, and incompatible media/schema + metadata through existing resolver behavior. +- Avoid retesting the framework's general ordered-step failure matrix; add only + interaction-specific composition cases not already covered generically. + +### Completion Criteria + +- Production registration exposes one complete type-consistent lane. +- A valid two-step NPC-to-interactions pipeline resolves and prepares. +- Invalid or missing NPC dependencies fail before source parsing or LLM work + whenever statically discoverable. + +## Stage 6 — Prove The Workflow And Publish Current Contracts + +### Goal + +Exercise the assembled generated-reference workflow and move all implemented +behavior into its canonical current documentation. + +### Changes + +1. Add one representative integration test under + `internal/modules/integration` with focused testdata: + - step 1 extracts and normalizes NPCs; + - step 2 consumes the generated NPC artifact in an interaction lane; + - the fake structured LLM returns multiple interaction kinds and at least + one name variant; + - the assertion proves the names-only handoff, current-transcript evidence, + canonical name, stable chronology, and final durable artifact. +2. Include one failure assertion showing that a rejected or absent normalized + NPC producer prevents interaction extraction. Reuse the existing runner + dependency behavior; do not duplicate every framework failure case. +3. Add `examples/dnd-npc-interactions.config.yml` as a complete copyable + two-step profile. The first step produces `npcs`; the second step binds that + exact step/lane artifact to its `npcs` reference and selects + `dnd/npc-interactions` for extract and normalize. +4. Add the example to the existing CLI production configuration contract test + so it is parsed and resolved offline. +5. Add the durable external contract at + `docs/integrations/dnd-npc-interaction-artifacts.md`. It owns schema + identity, JSON shape, categories, evidence semantics, ordering, + normalization, validator chains, generated-reference behavior, and manifest + metadata. +6. Update current canonical documentation: + - `docs/config.md` for module/validator catalogs, default chains, required + reference slot, and the maintained example; + - `docs/internal/modules.md` for implementation ownership, prompt inputs, + registry resolution, validation, merge, and normalization; + - `docs/internal/overview.md` for the implemented component inventory; + - `docs/internal/llm.md` only as needed to include the new lane in the + existing D&D prompt-order/cache policy without duplicating the manifest; + - `docs/integrations/dnd-npc-artifacts.md` to identify NPC interactions as a + consumer while retaining the rule that registry evidence is not event + evidence; and + - any directly affected configuration or integration links. +7. Remove the NPC-interactions entry from `docs/roadmap/future.md` once the + production behavior and current documentation are complete. Change + `docs/roadmap/dnd-npc-interactions.md` to `Status: Implemented` pending its + later retirement; do not leave it as the canonical current contract. +8. Create a small human-review worksheet or fixture set only if the repository + already has an appropriate non-test evaluation home. Otherwise record the + manual evaluation results in the implementation handoff rather than + inventing a new framework. Exercise all six categories, transitions, + repeated occurrences, mentions followed by presence, combat alignment + changes, cross-chunk repetition, and empty output on at least one intended + smaller model. Do not make model agreement a deterministic CI gate. + +### Validation + +Run focused tests while implementing, then run: + +```sh +go test ./... +go vet ./... +go build ./cmd/notarius +``` + +Also run `git diff --check`, inspect the complete documentation diff for +current-versus-future claims, and verify every new relative documentation link. + +### Completion Criteria + +- All repository checks pass offline. +- The maintained example resolves with an explicit earlier NPC producer. +- The integration test proves the accepted generated artifact, not registry + source references, grounds the later model request. +- Durable output contains only `name`, `kind`, and current-transcript + `source_refs` per occurrence. +- Current documentation owns implemented contracts, while the feature roadmap + is marked implemented and `future.md` no longer advertises the work as + pending. + +## Open Questions + +None. The feature roadmap and fixed decisions above define the product and +architectural choices required for implementation. diff --git a/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json b/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json new file mode 100644 index 0000000..76bd6a7 --- /dev/null +++ b/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.npc_interactions", + "type": "object", + "additionalProperties": false, + "required": ["interactions"], + "properties": { + "interactions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "source_refs"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["mentioned", "noncombat_presence", "dialogue", "combat_ally", "combat_opponent", "other"] + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/npcinteractions/codec.go b/internal/modules/dnd/codec/npcinteractions/codec.go new file mode 100644 index 0000000..5052240 --- /dev/null +++ b/internal/modules/dnd/codec/npcinteractions/codec.go @@ -0,0 +1,140 @@ +// Package npcinteractions encodes durable D&D NPC interaction artifacts. +package npcinteractions + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +const ( + SchemaID = "notarius.dnd.npc_interactions" + SchemaName = "notarius_dnd_npc_interactions_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_npc_interactions.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any { + return map[string]any{"interaction_count": len(value.Interactions)} +} + +func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd npc interaction list: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before semantic +// validators have approved a value. +func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) { + content, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode dnd npc interaction list: %w", err) + } + return content, nil +} + +func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.NPCInteractionList{}, err + } + if err := validate(value); err != nil { + return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. +func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var value dnd.NPCInteractionList + if err := decoder.Decode(&value); err != nil { + return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values") + } + return value, nil +} + +func validate(value dnd.NPCInteractionList) error { + if value.Interactions == nil { + return fmt.Errorf("interactions must be present") + } + for index, interaction := range value.Interactions { + prefix := fmt.Sprintf("interactions[%d]", index) + if strings.TrimSpace(interaction.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if !validInteractionKind(interaction.Kind) { + return fmt.Errorf("%s.kind must be supported", prefix) + } + if len(interaction.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) + } + for refIndex, ref := range interaction.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} + +func validInteractionKind(value dnd.NPCInteractionKind) bool { + switch value { + case dnd.NPCInteractionKindMentioned, + dnd.NPCInteractionKindNoncombatPresence, + dnd.NPCInteractionKindDialogue, + dnd.NPCInteractionKindCombatAlly, + dnd.NPCInteractionKindCombatOpponent, + dnd.NPCInteractionKindOther: + return true + default: + return false + } +} diff --git a/internal/modules/dnd/codec/npcinteractions/codec_test.go b/internal/modules/dnd/codec/npcinteractions/codec_test.go new file mode 100644 index 0000000..4577d76 --- /dev/null +++ b/internal/modules/dnd/codec/npcinteractions/codec_test.go @@ -0,0 +1,183 @@ +package npcinteractions + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func validList() dnd.NPCInteractionList { + return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ + { + Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, + }, + { + Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent, + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}, + }, + }} +} + +func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json") + if err != nil { + t.Fatal(err) + } + codec := New() + value, err := codec.Decode(raw) + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + if want := validList(); !reflect.DeepEqual(value, want) { + t.Fatalf("Decode() = %#v, want %#v", value, want) + } + encoded, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + t.Fatal(err) + } + if !bytes.Equal(encoded, compact.Bytes()) { + t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes()) + } +} + +func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { + codec := New() + schema := codec.Schema() + if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != 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) { + t.Fatalf("schema = %#v", schema) + } + registry := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { + t.Fatal(err) + } + spec, ok := registry.Spec(dnd.NPCInteractionListKind) + if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { + t.Fatalf("registered spec = %#v, %t", spec, ok) + } + if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCList{}); err == nil { + t.Fatal("Encode() error = nil, want exact type rejection") + } else { + var typeErr *pipeline.ArtifactCodecTypeError + if !errors.As(err, &typeErr) { + t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err) + } + } +} + +func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) { + codec := New() + empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}} + content, err := codec.Encode(empty) + if err != nil || string(content) != `{"interactions":[]}` { + t.Fatalf("Encode() = %s, %v", content, err) + } + for _, candidate := range []dnd.NPCInteractionList{ + {}, + empty, + {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}}, + {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}}, + {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}}, + } { + content, err := codec.EncodeCandidate(candidate) + if err != nil || !json.Valid(content) { + t.Fatalf("EncodeCandidate() = %s, %v", content, err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + } +} + +func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { + validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` + for _, test := range []struct{ name, raw, want string }{ + {"malformed", `{`, "decode dnd npc interaction list"}, + {"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"}, + {"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"}, + {"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, + {"trailing", `{"interactions":[]} {}`, "multiple JSON values"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) { + tests := []struct { + name string + value dnd.NPCInteractionList + want string + }{ + {"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"}, + {"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"}, + {"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"}, + {"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"}, + {"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"}, + {"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"}, + {"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCodecAcceptsEveryInteractionKind(t *testing.T) { + for _, kind := range []dnd.NPCInteractionKind{ + dnd.NPCInteractionKindMentioned, + dnd.NPCInteractionKindNoncombatPresence, + dnd.NPCInteractionKindDialogue, + dnd.NPCInteractionKindCombatAlly, + dnd.NPCInteractionKindCombatOpponent, + dnd.NPCInteractionKindOther, + } { + value := validList() + value.Interactions[0].Kind = kind + if _, err := New().Encode(value); err != nil { + t.Fatalf("Encode(%q) error = %v", kind, err) + } + } +} + +func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { + codec := New() + first := codec.Schema() + first.JSONSchema[0] = '[' + if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' { + t.Fatal("Schema() returned shared bytes") + } + metadata := codec.Metadata(validList()) + metadata["other"] = true + if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 { + t.Fatalf("Metadata() = %#v", next) + } +} + +func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList { + change(&value) + return value +} diff --git a/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json b/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json new file mode 100644 index 0000000..6e12074 --- /dev/null +++ b/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json @@ -0,0 +1,18 @@ +{ + "interactions": [ + { + "name": "Mira Thorn", + "kind": "dialogue", + "source_refs": [ + {"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2} + ] + }, + { + "name": "Hooded Guard", + "kind": "combat_opponent", + "source_refs": [ + {"source_id": "session-alpha", "start_unit_id": 3, "end_unit_id": 3} + ] + } + ] +} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 91b3828..4b9883a 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -12,6 +12,8 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list" const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list" +const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list" + type SpellList struct { SpellCasts []SpellCast `json:"spell_casts"` } @@ -51,3 +53,24 @@ type CombatTurn struct { TurnKind CombatTurnKind `json:"turn_kind"` SourceRefs []source.SourceRef `json:"source_refs"` } + +type NPCInteractionKind string + +const ( + NPCInteractionKindMentioned NPCInteractionKind = "mentioned" + NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence" + NPCInteractionKindDialogue NPCInteractionKind = "dialogue" + NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally" + NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent" + NPCInteractionKindOther NPCInteractionKind = "other" +) + +type NPCInteractionList struct { + Interactions []NPCInteraction `json:"interactions"` +} + +type NPCInteraction struct { + Name string `json:"name"` + Kind NPCInteractionKind `json:"kind"` + SourceRefs []source.SourceRef `json:"source_refs"` +}