Enforce unique enemy engagements per scene

This commit is contained in:
2026-08-03 22:50:41 +00:00
parent 42973215fa
commit e6b7c61f45
7 changed files with 536 additions and 0 deletions

363
docs/roadmap/combat.md Normal file
View File

@@ -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.

View File

@@ -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{

View File

@@ -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),

View File

@@ -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"),

View File

@@ -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) }},

View File

@@ -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 }

View File

@@ -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}}
}