Design doc at docs/architecture/interaction-memory-schema.md extending FactKnowledge with interaction tracking. FactMetadata enum on existing BTreeMap, 5-state InteractionState (Active/Completed/WalkedAway/ Confrontation/ConflictAbandoned), monologue prerequisite extension. Includes entity slug resolution approach, NpcTolerance reconciliation, and fact decay exemption documentation. Ref: D-041, D-064, D-063 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
27 KiB
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> FactIdformat is"category.topic"(string)FactKnowledgehasconfidence,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 < DirectKnowledgeState: Active, Contradicted, StaleKnowledgeSource: DirectObservation, Heard, ToldBy, Inferred, BackgroundRelationshipState: 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 Kaelinteraction.kael-davan_002— second tracked interaction with Kaelinteraction.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
FactMetadataenum 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.factsinfrastructure — 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-away–specific consequence parameters that ToleranceThreshold does not cover (penalty magnitude, forgiveness policy). The relationship:
NpcTolerance(content YAML) → populatesToleranceThreshold(ECS runtime)ToleranceThresholdtracks general stress accumulation — any irritant contributesInteractionTolerancetracks walk-away–specific 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:
- Check
InteractionTolerance.walk_away_threshold— has the NPC been walked away from too many times? - If threshold exceeded, apply
walk_away_penaltyto relationship - Increase
ToleranceThreshold.current_stressby a walk-away–specific delta - Check
permanent_confrontation_memoryfor confrontation abandonment
/// Walk-away–specific 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
ToleranceThresholdis the NPC's general frustration levelInteractionToleranceis 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
interactionprerequisite 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.