Files
settled-reach/docs/architecture/interaction-memory-schema.md
T
jpmschweitzerandClaude Opus 4.6 3100190b40 docs(docs): add frontmatter to architecture docs
Standardized YAML frontmatter on all 10 docs/architecture/ files with
title, description, type, status, ticket, decision_refs, and author
fields. Enables context-aware document loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:22:03 +01:00

27 KiB
Raw Blame History

title, description, type, status, ticket, decision_refs, author, created, updated
title description type status ticket decision_refs author created updated
InteractionMemory Knowledge Graph Schema Design Schema extension for tracking player-NPC conversation history in the knowledge graph — walk-away consequences, confrontation tracking, and monologue prerequisite integration architecture active #442
D-041
D-064
D-063
D-028
YYYY-MM-DD YYYY-MM-DD

InteractionMemory Knowledge Graph Schema Design

Ticket: #442 | Sprint: 7 | Priority: MEDIUM Decisions: D-041 (KG data model), D-064 (walk-away consequences), D-063 (confrontation), D-028 (dialogue architecture) Audience: Engine developers (Dudley), narrative designers (Paula, Mellanie), QA (Hoshe)


1. Purpose

The InteractionMemory schema extends the existing FactKnowledge system (D-041) to track conversation history between the player character and NPCs. This enables:

  • Walk-away consequences (D-064): The KG records that an interaction was started but not finished, affecting future dialogue and NPC behavior.
  • Confrontation tracking (D-063): The KG records whether an interaction escalated to confrontation.
  • Dialogue history queries: "Have I talked to X before?", "Did I walk away from X?", "When did I last complete a conversation with X?"
  • Monologue triggers: Post-interaction reflection that references prior conversation outcomes.

This is a schema design document. It defines data structures and query patterns — implementation follows in a future ticket.


2. Design Constraints

From D-041:

  • All maps are BTreeMap (deterministic iteration, non-negotiable per D-010 principle 4)
  • Facts live in KnowledgeGraph.facts: BTreeMap<FactId, FactKnowledge>
  • FactId format is "category.topic" (string)
  • FactKnowledge has confidence, source, state, acquired_tick

From D-064:

  • Walk-away triggers three phases: immediate break, NPC reaction, KG recording
  • KG must log that the interaction was initiated but not completed
  • This is queryable and affects future dialogue, monologue, and NPC behavior
  • NPC tolerance thresholds vary per seed (no universal rules to metagame)

From the existing codebase (server/src/knowledge/types.rs):

  • KnowledgeConfidence: Suspects < KnowsOf < KnowsDetails < Direct
  • KnowledgeState: Active, Contradicted, Stale
  • KnowledgeSource: DirectObservation, Heard, ToldBy, Inferred, Background
  • RelationshipState: Unknown, Known, Friendly, PersonOfInterest, Hostile

3. Schema Extension

3.1 Interaction FactId format

Interaction facts use a dedicated interaction category in the FactId namespace:

interaction.{entity_slug}_{sequence}
Segment Format Description
interaction literal Category prefix — distinguishes from other fact categories
entity_slug kebab-case NPC slug matching their content file (e.g., kael-davan, lera-sessik)
_sequence _NNN Zero-padded sequence number for multiple interactions with the same NPC

Examples:

  • interaction.kael-davan_001 — first tracked interaction with Kael
  • interaction.kael-davan_002 — second tracked interaction with Kael
  • interaction.sera-venn_001 — first tracked interaction with Sera

Why sequence numbers, not timestamps? FactIds are string keys in a BTreeMap. Sequence numbers are shorter, sortable, and don't tie the ID format to the tick system. The timestamp is stored in the metadata (see Section 3.3).

Entity slug resolution at runtime

The {entity_slug} segment in the FactId (e.g., kael-davan) must be resolvable from an Entity (bevy ECS handle) during gameplay. Currently, no component maps Entity → content slug at runtime.

Proposed approach: Add a ContentSlug component to the NPC entity bundle, populated from the NPC profile filename during spawn:

/// Stable content identifier for an entity, derived from its YAML profile filename.
/// e.g., NPC profile `kael-davan.yaml` → ContentSlug("kael-davan").
/// Used to construct FactIds like `interaction.kael-davan_001`.
#[derive(Component, Debug, Clone)]
pub struct ContentSlug(pub String);

The interaction event handler resolves Entity → ContentSlug → FactId:

// In InteractionStarted event processing:
let slug = world.get::<ContentSlug>(target)
    .expect("NPC must have ContentSlug component");
let seq = next_interaction_sequence(&kg.facts, &slug.0);
let fact_id = FactId(format!("interaction.{}_{:03}", slug.0, seq));

Implementation note: The ContentSlug component is a prerequisite for this schema. It should be added as part of the NPC spawn pipeline (#423 or a follow-up ticket) before interaction tracking is implemented. Until ContentSlug exists, the interaction system cannot construct FactIds.

3.2 InteractionState enum

New enum representing the outcome of an interaction:

/// Outcome state of a tracked interaction.
/// Drives walk-away mechanics (D-064) and confrontation tracking (D-063).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InteractionState {
    /// Conversation started, still in progress.
    /// Set when dialogue box opens. Cleared on transition to another state.
    Active,

    /// Conversation completed normally (both parties finished talking).
    /// The default "good" outcome.
    Completed,

    /// Player walked away mid-conversation (D-064 Phase 3).
    /// The KG records incompleteness. NPC tolerance threshold determines
    /// severity of consequence.
    WalkedAway,

    /// Conversation escalated to confrontation (D-063).
    /// Player made an accusation or NPC became hostile during dialogue.
    /// Separate from WalkedAway — a confrontation can be completed.
    Confrontation,

    /// Confrontation was initiated but player walked away before resolution.
    /// The most consequential outcome — walking away from a confrontation
    /// you started is worse than walking away from smalltalk.
    ConflictAbandoned,
}

State transitions:

                ┌─────────────┐
                │   Active    │ ← dialogue opens
                └──────┬──────┘
                       │
          ┌────────────┼────────────┐
          │            │            │
          ▼            ▼            ▼
    ┌───────────┐ ┌──────────┐ ┌──────────────┐
    │ Completed │ │ WalkedAway│ │Confrontation │
    └───────────┘ └──────────┘ └──────┬───────┘
                                      │
                            ┌─────────┼──────────┐
                            │                    │
                            ▼                    ▼
                      ┌───────────┐   ┌──────────────────┐
                      │ Completed │   │ConflictAbandoned  │
                      └───────────┘   └──────────────────┘

All transitions are one-way. Once an interaction reaches a terminal state (Completed, WalkedAway, ConflictAbandoned), it cannot change.

3.3 InteractionMetadata

New metadata struct attached to interaction facts. This extends the base FactKnowledge with interaction-specific data.

/// Metadata for an interaction fact.
/// Stored alongside the FactKnowledge in the knowledge graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionMetadata {
    /// The NPC this interaction was with.
    pub target_entity: StableId,

    /// Interaction outcome.
    pub interaction_state: InteractionState,

    /// Tick when dialogue was initiated.
    pub started_tick: u64,

    /// Tick when interaction ended (completed, walked away, etc.).
    /// None if interaction is still Active.
    pub ended_tick: Option<u64>,

    /// Primary conversation topic(s) discussed.
    /// References topic enum values from content/global/enums/topics.yaml.
    /// Empty if no specific topic was identified.
    pub topics: Vec<String>,

    /// Whether a confrontation occurred during this interaction.
    /// True if InteractionState is Confrontation or ConflictAbandoned.
    pub confrontation: bool,

    /// Whether the player initiated the conversation (approached NPC)
    /// or the NPC initiated (unprompted disclosure, D-028 Layer 4).
    pub player_initiated: bool,

    /// Location slug where the interaction took place.
    pub location: String,
}

3.4 Storage strategy

Interaction metadata is stored in a new field on FactKnowledge. There are two viable approaches:

Option A: Typed metadata field (recommended)

Add an optional metadata enum to FactKnowledge:

/// Optional typed metadata attached to a fact.
/// Extensible for future fact types beyond interactions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FactMetadata {
    Interaction(InteractionMetadata),
    // Future: Evidence(EvidenceMetadata), Gossip(GossipMetadata), etc.
}

/// Updated FactKnowledge with optional metadata.
pub struct FactKnowledge {
    pub confidence: KnowledgeConfidence,
    pub source: KnowledgeSource,
    pub state: KnowledgeState,
    pub acquired_tick: u64,
    pub metadata: Option<FactMetadata>,  // NEW
}

Why Option A over alternatives:

  • No separate data structure — interactions are facts, stored in the existing BTreeMap<FactId, FactKnowledge>
  • The FactMetadata enum is extensible for future fact types (evidence, gossip) without changing the graph structure
  • Option<FactMetadata> adds zero overhead to facts that don't need metadata (the vast majority)
  • Queries use existing KnowledgeGraph.facts infrastructure — no new maps to maintain

Option B (rejected): Separate InteractionLog map

A separate BTreeMap<InteractionId, InteractionRecord> on KnowledgeGraph. Rejected because it fragments the knowledge model — interactions ARE facts about the world, and should be queryable through the same API as other facts. Separate storage also complicates serialization and tier transitions (D-026).


4. Confidence Mapping

Interaction facts use the existing 4-level confidence hierarchy, but the levels have interaction-specific semantics:

Confidence Interaction meaning When set
Suspects Not used for interactions
KnowsOf "I talked to X" Set when dialogue starts
KnowsDetails "I talked to X about Y, and it ended with Z" Set when dialogue ends with meaningful outcome
Direct "I am talking to X right now" Set during active dialogue, cleared on end

Interaction facts start at Direct (active conversation) and settle to KnowsDetails when the conversation ends. They do not decay below KnowsDetails — you don't forget that you had a conversation.

Fact decay exemption status

The current decay_knowledge system (server/src/knowledge/events.rs:106) only iterates KnowledgeGraph.entities (EntityKnowledge), not KnowledgeGraph.facts (FactKnowledge). This means interaction facts stored in self.facts are inherently immune to decay today — no code path touches them.

However, this is an implementation coincidence, not an explicit guarantee. When a future fact decay system is built (to handle stale evidence, fading gossip, etc.), it must respect DECAY_FLOOR for interaction facts. Interaction facts should never decay below KnowsDetails — you remember that you had a conversation, even if other details fade.

Requirement for future fact decay implementation: Check FactMetadata::Interaction and clamp confidence at DECAY_FLOOR:

impl InteractionMetadata {
    /// Interaction facts do not decay below KnowsDetails.
    /// You remember that you had a conversation, even if details fade.
    pub const DECAY_FLOOR: KnowledgeConfidence = KnowledgeConfidence::KnowsDetails;
}

// In future fact decay system:
for (id, fact) in kg.facts.iter_mut() {
    if let Some(FactMetadata::Interaction(_)) = &fact.metadata {
        // Interaction facts: clamp at DECAY_FLOOR
        if fact.confidence.decayed() < InteractionMetadata::DECAY_FLOOR {
            continue; // Skip — would drop below floor
        }
    }
    // ... normal decay logic ...
}

5. Query Patterns

5.1 Core queries

These are the queries the dialogue system, monologue system, and NPC behavior need:

impl KnowledgeGraph {
    /// Has the player ever interacted with this entity?
    fn has_interacted_with(&self, target: StableId) -> bool;

    /// Get the most recent interaction with an entity.
    fn last_interaction_with(&self, target: StableId) -> Option<&InteractionMetadata>;

    /// Get all interactions with an entity, ordered by started_tick.
    fn interactions_with(&self, target: StableId) -> Vec<&InteractionMetadata>;

    /// Has the player walked away from this entity?
    fn has_walked_away_from(&self, target: StableId) -> bool;

    /// Count of interactions with an entity.
    fn interaction_count_with(&self, target: StableId) -> usize;

    /// Has the player ever had a confrontation with this entity?
    fn has_confronted(&self, target: StableId) -> bool;

    /// Is there currently an active (in-progress) interaction?
    fn active_interaction(&self) -> Option<&InteractionMetadata>;
}

5.2 Implementation sketch

All queries scan self.facts for entries with interaction.{slug} prefix and FactMetadata::Interaction metadata. Since BTreeMap iterates in lexicographic order, all interactions with the same entity are contiguous (they share the interaction.{slug} prefix).

/// Iterate all interaction facts for a given entity.
/// Uses BTreeMap range query for O(log N + K) where K = number of interactions.
fn interaction_facts_for<'a>(
    &'a self,
    entity_slug: &str,
) -> impl Iterator<Item = (&'a FactId, &'a InteractionMetadata)> {
    let prefix = format!("interaction.{entity_slug}_");
    self.facts
        .range(FactId(prefix.clone())..)
        .take_while(move |(id, _)| id.0.starts_with(&prefix))
        .filter_map(|(id, fact)| {
            match &fact.metadata {
                Some(FactMetadata::Interaction(meta)) => Some((id, meta)),
                _ => None,
            }
        })
}

Performance: At expected scale (10-30 interactions per playthrough), this is negligible. BTreeMap range queries are O(log N) to find the start, then O(K) to iterate matches. With N=50 total facts and K=5 interactions per NPC, this is ~6-8 comparisons + 5 iterations.

5.3 Dialogue system queries

The dialogue selection pipeline (D-028) needs interaction history for Layer 2 (relationship history):

Query Use case Implementation
"First time talking to X?" Greeting selection — fresh vs. returning interaction_count_with(target) == 0
"Did I walk away last time?" NPC reacts differently, may reference it last_interaction_with(target).state == WalkedAway
"How many times have we talked?" Familiarity progression interaction_count_with(target)
"Was there a confrontation?" Permanently shifts NPC posture has_confronted(target)
"Active conversation right now?" Block new dialogue initiation active_interaction().is_some()

5.4 Monologue system queries

The monologue system (D-032, D-035) uses interaction facts as prerequisites:

# Monologue line that fires after walking away from Kael
- id: the-last-shift_m_s_050
  text: "Shouldn't have left like that. Kael noticed."
  trigger: post_conversation
  prerequisites:
    facts:
      - fact_id: interaction.kael-davan_001
        min_confidence: knows_details
  tags: [kael, walk-away, regret]

For more precise gating on interaction outcome, the monologue system needs to check the InteractionMetadata.interaction_state field. This extends the prerequisite system:

# More specific: only fires if the player walked away
prerequisites:
  facts:
    - fact_id: interaction.kael-davan_001
      min_confidence: knows_details
  interaction:                         # NEW prerequisite type
    target: npc:kael-davan
    state: walked_away                 # matches InteractionState::WalkedAway

This extends the monologue prerequisite schema (Section 6).


6. Monologue Prerequisite Extension

The existing monologue-pool.schema.json supports facts, entity_attributes, and relationship prerequisites. Interaction memory adds a new prerequisite type:

"interaction_prerequisite": {
  "type": "object",
  "required": ["target"],
  "additionalProperties": false,
  "properties": {
    "target": {
      "type": "string",
      "description": "Entity reference (e.g., npc:kael-davan)"
    },
    "state": {
      "type": "string",
      "enum": ["active", "completed", "walked_away", "confrontation", "conflict_abandoned"],
      "description": "Required interaction outcome. If omitted, any interaction satisfies."
    },
    "min_count": {
      "type": "integer",
      "minimum": 1,
      "default": 1,
      "description": "Minimum number of interactions matching the criteria"
    }
  }
}

Added to the prerequisites object in the monologue schema:

"prerequisites": {
  "type": "object",
  "properties": {
    "facts": { ... },
    "entity_attributes": { ... },
    "relationship": { ... },
    "interaction": { "$ref": "#/$defs/interaction_prerequisite" }
  }
}

Examples:

# Fires after any interaction with Kael (first meeting)
prerequisites:
  interaction:
    target: npc:kael-davan

# Fires only if player walked away from Kael
prerequisites:
  interaction:
    target: npc:kael-davan
    state: walked_away

# Fires after 3+ completed conversations with Lera
prerequisites:
  interaction:
    target: npc:lera-sessik
    state: completed
    min_count: 3

# Combined: walked away from Kael AND knows about the ring
prerequisites:
  facts:
    - fact_id: contraband.ring_exists
      min_confidence: knows_of
  interaction:
    target: npc:kael-davan
    state: walked_away

7. Event Integration

7.1 New KnowledgeEventType variants

Extend KnowledgeEventType in server/src/knowledge/events.rs:

pub enum KnowledgeEventType {
    // ... existing variants ...

    /// Dialogue initiated with an NPC.
    InteractionStarted {
        target: Entity,
        player_initiated: bool,
        location: String,
    },

    /// Dialogue completed normally.
    InteractionCompleted {
        target: Entity,
        topics: Vec<String>,
    },

    /// Player walked away mid-conversation (D-064).
    InteractionWalkedAway {
        target: Entity,
    },

    /// Conversation escalated to confrontation (D-063).
    InteractionConfrontation {
        target: Entity,
    },

    /// Player walked away from a confrontation they started.
    InteractionConflictAbandoned {
        target: Entity,
    },
}

7.2 Event flow

Player approaches NPC → dialogue system opens
  │
  ├─ Emit InteractionStarted
  │  → KG creates interaction.{slug}_{seq} fact with InteractionState::Active
  │  → FactKnowledge.confidence = Direct (in conversation now)
  │
  ├─ ... conversation proceeds ...
  │
  └─ Conversation ends (one of):
     │
     ├─ Normal end → Emit InteractionCompleted
     │  → KG updates fact: Active → Completed, confidence → KnowsDetails
     │  → Record topics discussed
     │
     ├─ Player walks away (WASD) → Emit InteractionWalkedAway
     │  → KG updates fact: Active → WalkedAway, confidence → KnowsDetails
     │  → D-064 Phase 3: KG records incompleteness
     │
     ├─ Confrontation escalation → Emit InteractionConfrontation
     │  → KG updates fact: Active → Confrontation
     │  → Conversation continues (may still complete or be abandoned)
     │
     └─ Walk away from confrontation → Emit InteractionConflictAbandoned
        → KG updates fact: Confrontation → ConflictAbandoned
        → Most severe consequence path

7.3 Sequence number allocation

The process_knowledge_events system allocates sequence numbers when processing InteractionStarted events:

/// Determine next sequence number for an interaction FactId.
/// Scans existing facts for the highest sequence number with this prefix.
fn next_interaction_sequence(
    facts: &BTreeMap<FactId, FactKnowledge>,
    entity_slug: &str,
) -> u32 {
    let prefix = format!("interaction.{entity_slug}_");
    facts
        .range(FactId(prefix.clone())..)
        .take_while(|(id, _)| id.0.starts_with(&prefix))
        .count() as u32
        + 1
}

This is O(log N + K) — acceptable for the expected interaction count.


8. NPC Tolerance System

Per D-064, walk-away consequences vary by NPC tolerance threshold per seed. This is orthogonal to the KG schema — it lives on the NPC entity, not in the player's knowledge graph.

8.1 Relationship to existing tolerance types

The codebase already has two tolerance-related types:

Type Location Purpose
ToleranceThreshold server/src/npc/mod.rs:122 ECS Component. General NPC stress tracker with current_stress: i16 and threshold: i16. Drives overall NPC frustration behavior (D-024 Axis 4).
NpcTolerance server/src/content/types.rs:302 Content definition. Deserialization struct from NPC YAML profiles with threshold: Option<i32>. Populates ToleranceThreshold at spawn.

The proposed InteractionTolerance is not a replacement for either. It tracks walk-awayspecific consequence parameters that ToleranceThreshold does not cover (penalty magnitude, forgiveness policy). The relationship:

  • NpcTolerance (content YAML) → populates ToleranceThreshold (ECS runtime)
  • ToleranceThreshold tracks general stress accumulation — any irritant contributes
  • InteractionTolerance tracks walk-awayspecific consequence parameters — how the NPC reacts to conversational abandonment specifically

Recommended implementation approach: Rather than adding a third standalone component, extend the existing ToleranceThreshold or co-locate walk-away fields on a new InteractionTolerance component that reads ToleranceThreshold.current_stress as a factor. Walk-away consequences should feed back into ToleranceThreshold.current_stress (walking away from a stressed NPC is worse). The walk-away system would:

  1. Check InteractionTolerance.walk_away_threshold — has the NPC been walked away from too many times?
  2. If threshold exceeded, apply walk_away_penalty to relationship
  3. Increase ToleranceThreshold.current_stress by a walk-awayspecific delta
  4. Check permanent_confrontation_memory for confrontation abandonment
/// Walk-awayspecific consequence parameters. Extends general tolerance
/// (ToleranceThreshold) with interaction-specific mechanics per D-064.
/// Populated from NPC profile YAML, seeded per playthrough.
/// Reads ToleranceThreshold.current_stress as an aggravating factor.
#[derive(Component, Debug, Clone)]
pub struct InteractionTolerance {
    /// How many walk-aways before relationship degradation.
    /// Range: 1-5, seeded per NPC per playthrough.
    pub walk_away_threshold: u8,

    /// How much relationship damage per walk-away beyond threshold.
    /// Range: 0.1-0.5, seeded per NPC.
    pub walk_away_penalty: f32,

    /// Stress delta applied to ToleranceThreshold.current_stress per walk-away.
    /// A walk-away from a stressed NPC (high current_stress) hits harder.
    pub walk_away_stress_delta: i16,

    /// Whether this NPC remembers confrontation abandonment permanently.
    /// Some NPCs forgive, some don't (per seed).
    pub permanent_confrontation_memory: bool,
}

The NPC behavior system queries both the player's KG (for interaction history) and the NPC's tolerance components (for consequence magnitude). This separation means:

  • The player's KG is what THEY remember
  • ToleranceThreshold is the NPC's general frustration level
  • InteractionTolerance is how the NPC reacts to walk-aways specifically (feeding back into general stress)
  • The NPC may also have their own KG entry for the interaction (future: NPC-to-NPC gossip about "the person who walked out on Kael")

9. Scope and Limitations

9.1 v0.1 scope (this ticket)

  • Player character interaction tracking only
  • Interactions with Active-tier NPCs (30-80 per D-026)
  • Single interaction prerequisite per monologue line
  • No NPC-to-NPC interaction tracking
  • No gossip about interactions ("I heard you walked out on Kael")

9.2 Future extensions

Feature Ticket/Sprint Schema impact
NPC-to-NPC interaction tracking Future Same schema, applied to NPC KnowledgeGraphs
Gossip about interactions Sprint 3+ (ToldBy source) New KnowledgeSource::ToldBy entries referencing interaction facts
Interaction-driven relationship decay Future Query interaction history to modulate decay rates
Multi-NPC conversations Future InteractionMetadata.target_entity becomes Vec<StableId>

9.3 Memory budget

Per-interaction memory cost: ~200 bytes (InteractionMetadata + FactKnowledge overhead).

Expected interactions per playthrough: 50-150 (2-5 per Active NPC).

Total interaction memory: ~10-30 KB per player character. Negligible against the D-041 budget of ~6 MB for the full knowledge graph.


10. Schema Summary

Component Location Type Description
InteractionState server/src/knowledge/types.rs enum 5 outcome states
InteractionMetadata server/src/knowledge/types.rs struct Per-interaction data
FactMetadata server/src/knowledge/types.rs enum Extensible typed metadata on FactKnowledge
FactKnowledge.metadata server/src/knowledge/types.rs Option<FactMetadata> New field on existing struct
ContentSlug server/src/npc/ (proposed) Component Entity → content slug mapping for FactId construction
InteractionTolerance NPC template system Component Walk-away consequence parameters (extends ToleranceThreshold)
interaction_prerequisite content/_schema/monologue-pool.schema.json schema New prerequisite type
KnowledgeEventType variants server/src/knowledge/events.rs enum variants 5 new event types
Interaction FactId format Convention interaction.{slug}_{NNN} Fact category for interactions

Breaking changes

One field addition to an existing struct:

 pub struct FactKnowledge {
     pub confidence: KnowledgeConfidence,
     pub source: KnowledgeSource,
     pub state: KnowledgeState,
     pub acquired_tick: u64,
+    pub metadata: Option<FactMetadata>,
 }

This is an additive change. Existing facts have metadata: None. All existing queries continue to work unchanged. The only code change required beyond adding the field is to add metadata: None to existing FactKnowledge construction sites.