Enforce unique enemy engagements per scene
This commit is contained in:
363
docs/roadmap/combat.md
Normal file
363
docs/roadmap/combat.md
Normal 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.
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) }},
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}}
|
||||
}
|
||||
Reference in New Issue
Block a user