diff --git a/docs/roadmap/combat.md b/docs/roadmap/combat.md new file mode 100644 index 0000000..88ce396 --- /dev/null +++ b/docs/roadmap/combat.md @@ -0,0 +1,363 @@ +# Combat Enemy Event Extraction + +## Purpose + +Define a minimal, evidence-grounded D&D artifact that records enemies engaged +and their explicitly observed combat outcomes. The artifact is the durable +primitive from which an end-of-session encounter ledger can be derived; this +scope does not ask an LLM to synthesize a final ledger or infer an outcome from +silence. + +This roadmap follows +[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md): +the extractor answers one narrow question, every reported fact has direct +transcript evidence, and generated artifacts provide grounding rather than +evidence. + +## Context + +Notarius can currently identify NPCs, classify NPC interactions, record combat +turns, and classify scenes. Those artifacts answer useful but different +questions: + +- the NPC registry establishes individually identifiable names; +- NPC interactions identify such occurrences as `combat_opponent` but do not + record outcomes; +- combat turns establish actors and action boundaries but deliberately omit + action contents and state; and +- scene descriptions determine which chunks are combat scenes. + +None can establish by itself that an opponent was killed, fled, captured, or +incapacitated. Deriving those outcomes from an absent later turn, the end of a +scene, or a combat-opponent classification would create unsupported claims. +The new extractor therefore reads the combat transcript directly while using +the four normalized artifacts above as explicit auxiliary grounding. + +## Goals + +- Produce an ordered list of directly evidenced enemy engagement and outcome + events. +- Keep the durable event shape to `name`, `kind`, and `source_refs`. +- Run the LLM only for chunks with an exact `combat` scene classification. +- Ground named subjects in the normalized NPC registry and use prior combat + artifacts to constrain participation and allegiance without treating them as + evidence. +- Support unnamed individuals and groups without inventing synthetic member + identities. +- Preserve repeated engagements and state changes rather than collapsing them + into one synthesized terminal record. +- Integrate the lane into the maintained complete D&D pipeline as a later + ordered step. +- Follow the existing D&D codec, prompt, normalization, validation, + registration, evidence-publication, checkpoint, and documentation + conventions. + +## Target End State + +### Durable Artifact Contract + +The new production module key is `dnd/enemy-events`. Its typed artifact is: + +| Property | Value | +| --- | --- | +| Artifact kind | `dnd/enemy-event-list` | +| Schema ID | `notarius.dnd.enemy_events` | +| Schema name | `notarius_dnd_enemy_events_v1` | +| Schema version | `v1` | +| Media type | `application/json` | +| Root field | `events` | + +The root is a strict object with one required `events` array. The array may be +empty. Each event is a strict object with exactly these required fields: + +| Field | Contract | +| --- | --- | +| `name` | Non-empty display name or directly grounded collective subject label. | +| `kind` | `engaged`, `killed`, `fled`, `captured`, or `incapacitated`. | +| `source_refs` | One or more inclusive current-transcript evidence ranges. | + +Each source reference has exactly `source_id`, `start_unit_id`, and +`end_unit_id`, following the existing generic source-reference contract. The +durable schema rejects unknown fields and incompatible types. + +Example: + +```json +{ + "events": [ + { + "name": "Ashfang", + "kind": "engaged", + "source_refs": [ + {"source_id": "session-7", "start_unit_id": 41, "end_unit_id": 42} + ] + }, + { + "name": "Ashfang", + "kind": "fled", + "source_refs": [ + {"source_id": "session-7", "start_unit_id": 57, "end_unit_id": 58} + ] + } + ] +} +``` + +The artifact does not contain an NPC ID, scene ID, quantity, confidence, +description, rationale, summary, current state, or inferred terminal outcome. +Scene membership remains deterministically derivable by intersecting event +evidence with scene ranges. Cross-artifact durable NPC IDs remain governed by +the separate future identity-link design consideration. + +### Event Semantics + +| Kind | Required evidence | +| --- | --- | +| `engaged` | The subject is directly established as actively opposing the party in combat. Emit at most one engagement for the same subject in one combat scene. | +| `killed` | The transcript explicitly establishes that the subject died or was killed. Damage, defeat, disappearance, or the end of combat is insufficient. | +| `fled` | The subject explicitly escapes, retreats, or otherwise leaves the combat to avoid continued engagement. Mere movement or absence from later turns is insufficient. | +| `captured` | The subject is explicitly taken prisoner or secured under the party's control. A grapple or temporary restraint alone is insufficient. | +| `incapacitated` | The subject is explicitly rendered unable to continue acting without being established as killed or captured. Do not infer this from a missed turn. | + +An outcome may share evidence with an engagement, in which case both events +are emitted. A later engagement for the same named subject is preserved, even +after an earlier outcome, because enemies may escape, recover, return, or be +revived. The event list records observations rather than asserting irreversible +state transitions. + +`active` and `unresolved` are not model-produced event kinds. A downstream +ledger may report the most recent explicit outcome for a subject and use +`unresolved` when an engagement has no later evidenced outcome. It must not use +`active` merely because no terminal event was extracted. A distinct future +contract would be required if operators need an evidenced ongoing-combat state. + +### Subject Identity And Groups + +- An individually named subject that matches the normalized NPC registry uses + the registry's canonical display name. +- A hostile creature or summoned entity is included when it directly opposes + the party, whether or not it has an NPC-registry entry. +- An unnamed homogeneous group is represented collectively with the narrowest + transcript-grounded label, such as `Orcs`. The extractor does not invent + `Orc 1`, `Orc 2`, or other synthetic identities. +- When evidence applies only to a subgroup or one unidentified member, use the + narrowest supported phrase, such as `One orc` or `Remaining orcs`. Do not + claim a deterministic identity link between that phrase and another group + event. +- Named subjects may be compared across scenes through the canonical NPC name. + Generic or collective subjects are scoped to their combat scene by their + evidence range and are not assumed to be the same group in another scene. +- Party members, combat allies, neutral observers, mentioned-but-absent + enemies, hazards, traps, and environmental effects are excluded. Uncertain + allegiance is not enough to emit an event. + +These conservative rules prefer separate, explicitly evidenced observations +over a falsely precise encounter roster. + +### Ordered Pipeline And References + +The extractor is an LLM-backed lane in a third maintained D&D pipeline step. +It requires four JSON artifact reference slots at extraction: + +| Slot | Artifact kind | Purpose | +| --- | --- | --- | +| `npcs` | `dnd/npc-list` | Canonical grounding for individually named subjects. | +| `scene_descriptions` | `dnd/scene-description-list` | Eligibility gate for exact combat scenes. | +| `combat_turns` | `dnd/combat-turn-list` | Grounding for observed combat actors and action boundaries. | +| `npc_interactions` | `dnd/npc-interaction-list` | Grounding for named `combat_opponent` occurrences. | + +Each slot is required, accepts `application/json`, accepts only its listed +artifact kind, and has a 1,048,576-byte limit consistent with the current D&D +registry references. The framework may satisfy the contract with a compatible +external artifact, but the maintained complete pipeline uses generated +references from earlier steps. + +The deterministic normalizer requires only the `npcs` slot. The other three +artifacts inform extraction and scene routing but do not participate in final +name canonicalization. + +The maintained complete pipeline has this topology: + +```text +describe-session + -> npcs + -> scene-descriptions + +extract-events + -> combat-turns + -> npc-interactions + +track-enemies + -> enemy-events +``` + +`track-enemies` consumes `npcs` and `scene-descriptions` from +`describe-session`, and `combat-turns` and `npc-interactions` from +`extract-events`. An invalid, rejected, absent, ambiguous, or incompatible +upstream generated artifact prevents the dependent step from starting under +the existing single-run failure semantics. No new DAG or workflow mechanism is +introduced. + +### Eligibility And Grounding Projections + +The extractor reuses the scene-description registry's exact chunk matching: + +- exact `combat` match: prepare grounding inputs and call the LLM; +- exact non-combat match: return an accepted non-nil empty event list without + an LLM call; and +- missing or mismatched classification: return an accepted non-nil empty event + list with the bounded `scene_classification_unavailable` warning and no LLM + call. + +Auxiliary artifacts are decoded and projected into compact, canonical prompt +inputs: + +- NPCs retain only canonical `name` values; +- combat turns retain only `actor` and `turn_kind`; +- NPC interactions retain only `name` and `kind`, and only + `combat_opponent` records are included; and +- scene descriptions are used only for eligibility and are not rendered into + the extraction prompt. + +All artifact IDs, source references, summaries, producer provenance, and +origin URIs are excluded from the projections. Projection content receives its +own digest. Full generated-reference content and producer provenance continue +to participate in the framework's existing handoff and checkpoint identity. +Neither a projection nor its source references may be copied into event +evidence. + +### Prompt And Private Response Contract + +The extractor uses prompt ID `dnd.enemy_events`, PromptKit default profile +`dnd-extraction`, and a strict private response schema named +`notarius_dnd_enemy_events_llm_v1`. The private schema contains required +`name`, `kind`, and segment-range candidates; deterministic mapping attaches +the current source ID and preserves semantic candidates for validators. + +Prompt construction follows the shared D&D extraction convention. The first +four rendered messages remain exactly the shared system, identity, campaign +reference, and chunk-transcript messages in their existing cache-control +arrangement. Evidence policy, the NPC registry, module-owned compact combat +grounding, task, and final instructions follow the transcript. The new module +reuses shared assets instead of copying their text, and its final instructions +message carries ephemeral cache control. + +The prompt makes clear that grounding artifacts are hints, not proof; the +current combat transcript is the only evidence; every event requires its own +chunk-confined ranges; and absence from a turn list or opponent list does not +establish an outcome. + +### Deterministic Normalization + +`dnd/enemy-events` also identifies a deterministic normalizer for the same +typed artifact. It: + +1. clones caller-owned data; +2. collapses display whitespace in every subject name; +3. replaces a recognized subject with the canonical NPC-registry display + name, while retaining normalized unmatched group labels; +4. canonically orders and removes exact duplicate source ranges; +5. orders events by earliest valid source position, then normalized comparison + name, display name, the explicit kind order `engaged`, `incapacitated`, + `captured`, `fled`, `killed`, and complete reference sequence; invalid + evidence sorts after valid evidence for deterministic validation; and +6. collapses only records with the same normalized subject identity, kind, and + complete canonical evidence sequence, retaining the earliest display value. + +Distinct event kinds, distinct evidence, repeated engagements in different +scenes, and later outcome changes remain separate. Normalization never +manufactures an engagement or outcome and never aggregates the list into a +ledger. It is idempotent and emits bounded warnings for name changes, evidence +normalization, reordering, and exact duplicate collapse. + +### Validation And Production Policy + +The D&D registrar provides these validators: + +- `extract/dnd/enemy-events/shape`; +- `extract/dnd/enemy-events/engagements`; +- `extract/dnd/enemy-events/source_refs`; +- `extract/dnd/enemy-events/source_relatedness`; and +- `normalize/dnd/enemy-events/invariants`. + +The extraction default chain is: + +```text +generic/valid_json +extract/dnd/enemy-events/shape +extract/dnd/enemy-events/engagements +extract/dnd/enemy-events/source_refs +generic/valid_json_schema +extract/dnd/enemy-events/source_relatedness +``` + +The normalization default chain inserts +`normalize/dnd/enemy-events/invariants` after the shape validator. Shape owns +non-empty names, the closed event-kind vocabulary, and non-empty evidence. +Source-reference validation owns current-document range validity. Durable +schema validation owns the public JSON shape. Relatedness remains advisory. +The invariant validator owns canonical display values, registry-aware +canonical names where applicable, canonical evidence, deterministic ordering, +and absence of exact duplicates. + +No production LLM-backed validator is added. Semantic quality should first be +evaluated through the extractor prompt and human review rather than adding a +second model judgment without a concrete policy. + +The registrar also supplies the strict codec, typed append-order merger, +deterministic normalizer, typed no-op normalizer compatibility, typed evidence +projection, prompt and schema assets, safe manifest metadata, and stable +checkpoint fingerprints. Operation-varying generated reference content stays +in framework-owned reference/checkpoint identity rather than static module +metadata. + +### Output, Examples, And Documentation + +The JSON output encoder publishes normalized enemy events like every other +typed lane. The maintained complete example adds `enemy-events` to its evidence +context allowlist and demonstrates the third generated-reference step. The +minimal example remains unchanged. + +When implemented, canonical documentation adds a D&D enemy-event integration +contract, adds the production keys, slots, and default chains to +`docs/config.md`, and updates `docs/internal/dnd.md` for the seventh lane, +grounding projections, prompt ordering, eligibility, and normalization. +Current-behavior documents do not describe the feature before its code lands. + +## Out Of Scope + +- A separately published aggregated enemy-ledger artifact or inventory-like + state store. +- Model-produced `active`, `unresolved`, confidence, rationale, description, or + summary fields. +- Synthetic identities or quantities for indistinguishable enemies. +- Durable NPC-ID links, alias resolution beyond the normalized registry, or a + general cross-artifact entity graph. +- Inferring outcomes from turn absence, scene termination, hit-point guesses, + initiative order, or other artifacts. +- Deriving enemy events solely from combat turns or NPC interactions. +- Changing existing combat-turn, NPC, NPC-interaction, or scene-description + schemas. +- A general workflow DAG, general reference-query system, or generic semantic + deduplication framework. +- Paid or live-model tests in the default suite. + +## Acceptance Criteria + +- `dnd/enemy-events` produces the strict minimal `v1` event artifact and + preserves direct transcript evidence for every record. +- Only exact combat scenes invoke the LLM; non-combat and unclassified chunks + follow the defined empty-result behavior. +- The extractor consumes validated NPC, scene-description, combat-turn, and + NPC-interaction references through source-free grounding projections. +- Named enemies canonicalize through the NPC registry while unmatched + individuals and collective labels remain supported without invented IDs. +- The normalizer is deterministic, idempotent, ownership-safe, warning-bounded, + and collapses exact duplicates only. +- The codec, validators, default chains, merger, no-op compatibility, evidence + projector, prompt assets, fingerprints, and module specs are registered by + the D&D family. +- The maintained complete pipeline resolves and runs the new third step with + compatible generated references from both earlier steps. +- Canonical documentation and maintained examples describe only implemented + behavior, remain secret-free, and pass their existing offline contract tests. diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 4efb310..56593c0 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -68,6 +68,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "extract/dnd/item-events/source_relatedness", "normalize/dnd/item-events/invariants", "extract/dnd/enemy-events/shape", + "extract/dnd/enemy-events/engagements", "extract/dnd/enemy-events/source_refs", "extract/dnd/enemy-events/source_relatedness", "normalize/dnd/enemy-events/invariants", @@ -140,6 +141,17 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) { t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) } + enemyEventExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/enemy-events/shape"), + pipeline.Binding("extract/dnd/enemy-events/engagements"), + pipeline.Binding("extract/dnd/enemy-events/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/enemy-events/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) { + t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain) + } assetNames := productionAssetNames(t, components.assets.PromptFS) requiredAssets := []string{ diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index eec4a97..cc3a176 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -20,6 +20,7 @@ import ( combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements" enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants" enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" @@ -140,6 +141,7 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { Validators: []pipeline.ModuleBinding{ pipeline.Binding(validjson.Key), pipeline.Binding(enemyeventshape.Key), + pipeline.Binding(enemyeventengagements.Key), pipeline.Binding(enemyeventrefs.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(enemyeventrelatedness.Key), diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index e633cce..3b6cb71 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -101,6 +101,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", "extract/dnd/enemy-events/shape", + "extract/dnd/enemy-events/engagements", "extract/dnd/enemy-events/source_refs", "extract/dnd/enemy-events/source_relatedness", "normalize/dnd/enemy-events/invariants", @@ -179,6 +180,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { enemyEventExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/enemy-events/shape"), + pipeline.Binding("extract/dnd/enemy-events/engagements"), pipeline.Binding("extract/dnd/enemy-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/enemy-events/source_relatedness"), diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index 9ce94fb..295cc83 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -7,6 +7,7 @@ import ( combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements" enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants" enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" @@ -51,6 +52,7 @@ func registerValidators(registries pipeline.Registries) error { {name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }}, {name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }}, {name: "enemy event shape validator", register: func() error { return enemyeventshape.Register(registries.Validators) }}, + {name: "enemy event engagement validator", register: func() error { return enemyeventengagements.Register(registries.Validators) }}, {name: "enemy event source references validator", register: func() error { return enemyeventrefs.Register(registries.Validators) }}, {name: "enemy event source relatedness validator", register: func() error { return enemyeventrelatedness.Register(registries.Validators) }}, {name: "enemy event normalized invariants validator", register: func() error { return enemyeventinvariants.Register(registries.Validators) }}, diff --git a/internal/modules/dnd/validate/enemyevents/engagements/validator.go b/internal/modules/dnd/validate/enemyevents/engagements/validator.go new file mode 100644 index 0000000..3678d2a --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/engagements/validator.go @@ -0,0 +1,85 @@ +// Package engagements prevents duplicate enemy engagements within one scene. +package engagements + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" +) + +const ( + Key = "extract/dnd/enemy-events/engagements" + ReasonCode = "duplicate_enemy_engagement" + policy = "dnd.enemy_events.validator.engagements.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) { + if enemyeventshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + seen := make(map[string]struct{}) + for _, event := range req.Value.Events { + if event.Kind != dnd.EnemyEventKindEngaged { + continue + } + identity := enemyeventmodel.ComparisonKey(event.Name) + if identity == "" { + continue + } + if _, found := seen[identity]; found { + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("duplicate enemy engagement", []string{ + fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)), + }), + }, nil + } + seen[identity] = struct{}{} + } + return contracts.ValidationResult{Approved: true}, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/enemyevents/engagements/validator_test.go b/internal/modules/dnd/validate/enemyevents/engagements/validator_test.go new file mode 100644 index 0000000..733daf9 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/engagements/validator_test.go @@ -0,0 +1,70 @@ +package engagements + +import ( + "context" + "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 TestValidatorEnforcesOneEngagementPerSubject(t *testing.T) { + for _, test := range []struct { + name string + value dnd.EnemyEventList + approved bool + }{ + {name: "empty", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}, approved: true}, + {name: "one engagement", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1)), approved: true}, + {name: "same subject different evidence", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindEngaged, 2))}, + {name: "whitespace and case variant", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event(" ASHFANG ", dnd.EnemyEventKindEngaged, 2))}, + {name: "different subjects", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Briar", dnd.EnemyEventKindEngaged, 2)), approved: true}, + {name: "different kinds", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindFled, 2)), approved: true}, + {name: "malformed shape", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}, {Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(2)}}}, approved: true}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: test.value}) + if err != nil || result.Approved != test.approved { + t.Fatalf("Validate() = %#v, %v; want approved=%t", result, err, test.approved) + } + if !test.approved && result.ReasonCode != ReasonCode { + t.Fatalf("reason code = %q, want %q", result.ReasonCode, ReasonCode) + } + }) + } + for _, unitID := range []int{1, 2} { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: events(event("Ashfang", dnd.EnemyEventKindEngaged, unitID))}) + if err != nil || !result.Approved { + t.Fatalf("separate scene validation = %#v, %v; want approved", result, err) + } + } +} + +func TestValidatorRegistersStrictOptionsAndPolicy(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", Spec()) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("DecodeOptions() error = %v, want strict option rejection", err) + } +} + +func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} } + +func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent { + return dnd.EnemyEvent{Name: name, Kind: kind, SourceRefs: refs(unitID)} +} + +func refs(unitID int) []source.SourceRef { + return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}} +}