Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
# 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:
|
||||
|
||||
```rust
|
||||
/// 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:
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
/// 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.
|
||||
|
||||
```rust
|
||||
/// 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`:
|
||||
|
||||
```rust
|
||||
/// 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`:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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).
|
||||
|
||||
```rust
|
||||
/// 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:
|
||||
|
||||
```yaml
|
||||
# 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:
|
||||
|
||||
```yaml
|
||||
# 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:
|
||||
|
||||
```json
|
||||
"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:
|
||||
|
||||
```json
|
||||
"prerequisites": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"facts": { ... },
|
||||
"entity_attributes": { ... },
|
||||
"relationship": { ... },
|
||||
"interaction": { "$ref": "#/$defs/interaction_prerequisite" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
# 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`:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
/// 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) → populates `ToleranceThreshold` (ECS runtime)
|
||||
- `ToleranceThreshold` tracks **general stress accumulation** — any irritant contributes
|
||||
- `InteractionTolerance` tracks **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:
|
||||
|
||||
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-away–specific delta
|
||||
4. Check `permanent_confrontation_memory` for confrontation abandonment
|
||||
|
||||
```rust
|
||||
/// 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
|
||||
- `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:
|
||||
|
||||
```diff
|
||||
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.
|
||||
@@ -0,0 +1,582 @@
|
||||
# Line Pool Format Specification
|
||||
|
||||
**Ticket:** #308 | **Sprint:** 7 | **Priority:** HIGH (blocks #326, #305)
|
||||
**Decisions:** D-028 (dialogue architecture), D-032 (separate monologue pools), D-035 (tag taxonomy), D-041 (KG data model)
|
||||
**Audience:** Content authors (Mellanie, Paula), engine developers (Dudley), QA (Hoshe)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Line pools are the atomic content units for The Settled Reach's dialogue and monologue systems. Each pool is a YAML file containing tagged lines that the engine selects from at runtime using a four-layer filtering pipeline (D-028).
|
||||
|
||||
There are two pool types:
|
||||
|
||||
| Pool type | Scope | Selection model | Partition |
|
||||
|-----------|-------|-----------------|-----------|
|
||||
| **Dialogue** | Per-location, per-role | 4-layer filter (access > situation > trust > topic+mood) | None (role-based, character-agnostic) |
|
||||
| **Monologue** | Per-location, per-character | Trigger-based with prerequisite gates | Hard partition by character (D-032) |
|
||||
|
||||
Both types share a common tag vocabulary defined in `content/global/enums/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Directory Structure
|
||||
|
||||
Content lives under `content/campaigns/{campaign}/systems/{system}/stations/{station}/districts/{district}/`. Below the district level:
|
||||
|
||||
```
|
||||
{district}/
|
||||
dialogue/
|
||||
{location}/ # one subdirectory per location
|
||||
{role}.yaml # one file per template role at that location
|
||||
{named-npc}.yaml # named NPCs get dedicated files
|
||||
pc-smuggler.yaml # PC-specific dialogue (if applicable)
|
||||
pc-detective.yaml
|
||||
monologue/
|
||||
smuggler/ # hard partition per D-032
|
||||
general.yaml # location-independent lines
|
||||
{location}.yaml # location-specific lines
|
||||
{topic-slug}.yaml # topic-specific cross-location pools
|
||||
detective/
|
||||
general.yaml
|
||||
{location}.yaml
|
||||
{topic-slug}.yaml
|
||||
```
|
||||
|
||||
### Naming conventions
|
||||
|
||||
| Element | Pattern | Examples |
|
||||
|---------|---------|----------|
|
||||
| Location directory | `kebab-case` matching location YAML slug | `the-terminal`, `the-last-shift`, `maintenance-corridors` |
|
||||
| Dialogue file | `{role-slug}.yaml` or `{npc-slug}.yaml` | `dock-worker.yaml`, `kael-davan.yaml`, `pc-detective.yaml` |
|
||||
| Monologue file | `{location-slug}.yaml` or `general.yaml` or `{topic}.yaml` | `the-terminal.yaml`, `general.yaml`, `pc-detective-tells.yaml` |
|
||||
| Character directory | `smuggler/` or `detective/` | Matches `character` enum exactly |
|
||||
|
||||
### What is NOT encoded in per-line tags
|
||||
|
||||
Per D-035, the following are **implicit from directory structure** and never appear as line-level tags:
|
||||
|
||||
- **Location** — derived from the parent directory name
|
||||
- **Content type** — derived from whether the file is under `dialogue/` or `monologue/`
|
||||
- **Character** (monologue only) — derived from the parent `smuggler/` or `detective/` directory, confirmed by the `character` field in the YAML header
|
||||
|
||||
---
|
||||
|
||||
## 3. Dialogue Pool Format
|
||||
|
||||
### 3.1 File structure
|
||||
|
||||
```yaml
|
||||
# {NPC name or role description}
|
||||
# Context notes for authors (not consumed by engine)
|
||||
|
||||
location: {location-slug} # required, must match parent directory name
|
||||
role: {role-slug} # required, template role (not NPC name)
|
||||
lines:
|
||||
- id: {line-id}
|
||||
text: "Dialogue line text."
|
||||
role: {role-slug}
|
||||
access: [{access-tier}, ...]
|
||||
trust: {trust-tier}
|
||||
situation: [{situation}, ...]
|
||||
topic: [{topic}, ...] # optional, defaults to []
|
||||
mood: [{mood}, ...] # optional, defaults to []
|
||||
tags: [{freeform}, ...] # optional, defaults to []
|
||||
knowledge_grant: # optional
|
||||
fact_id: {category.fact_id}
|
||||
confidence: {confidence-level}
|
||||
```
|
||||
|
||||
### 3.2 Header fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `location` | string | YES | Location slug, must match parent directory. Pattern: `^[a-z][a-z0-9-]*$` |
|
||||
| `role` | string | YES | Template role slug. Pattern: `^[a-z][a-z0-9-]*$`. This is the abstract role (e.g. `dock-worker`), not a specific NPC name. NPC assignment to roles happens at runtime via template instantiation. |
|
||||
|
||||
### 3.3 Line fields
|
||||
|
||||
#### Structural tags (required on every line)
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | string | YES | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | Stable line identifier. See [Section 5: ID Format](#5-id-format). |
|
||||
| `text` | string | YES | Non-empty | The authored dialogue line. No length limit (unlike monologue). |
|
||||
| `role` | string | YES | `^[a-z][a-z0-9-]*$` | Template role this line belongs to. Must match header `role` or be a valid role at this location. |
|
||||
| `access` | list\<enum\> | YES | Min 1 item, unique | Access tiers this line is eligible for. **Hard filter** — line is invisible if player's access tier is not in this list. Values: `public`, `insider`, `authority`, `peer`, `hostile`. |
|
||||
| `trust` | enum | YES | Single value | Minimum trust tier required. **Hard filter** — line is invisible below this tier. Values: `surface`, `real`, `secret`. |
|
||||
| `situation` | list\<enum\> | YES | Min 1 item, unique | Situation contexts when this line can fire. **Context filter** — engine activates situations based on simulation state. Values: `arrival`, `shift_start`, `shift_end`, `shift_transition`, `bar_evening`, `night_shift`, `investigation`, `confrontation`, `social`, `alone`, `emergency`, `routine`, `observation`. |
|
||||
|
||||
#### Selection tags (optional, influence weighted selection)
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `topic` | list\<enum\> | no | `[]` | Topic tags for Layer 4 weighted selection. Lines without topic tags are eligible for any topic context. Values: `colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation`. |
|
||||
| `mood` | list\<enum\> | no | `[]` | Mood tags for Layer 4 weighted selection. Lines without mood tags are eligible for any mood context. Values: `fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`. |
|
||||
| `tags` | list\<string\> | no | `[]` | Freeform escape hatch. Not consumed by the filtering engine — used for author organization, content queries, and the line previewer. No validation on values. |
|
||||
|
||||
#### Knowledge grant (optional)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `knowledge_grant` | object | no | Knowledge the player gains from hearing this line. |
|
||||
| `knowledge_grant.fact_id` | string | YES (if grant present) | Fact identifier in `{category}.{fact_id}` format. Must reference a fact defined in `content/global/knowledge/{category}.yaml`. |
|
||||
| `knowledge_grant.confidence` | enum | YES (if grant present) | Confidence level granted. Values: `suspects`, `knows_of`, `knows_details`, `direct`. |
|
||||
|
||||
### 3.4 Four-layer filtering pipeline (D-028)
|
||||
|
||||
The engine processes dialogue lines through four layers in sequence:
|
||||
|
||||
```
|
||||
All lines in pool
|
||||
│
|
||||
├─ Layer 1: ACCESS FILTER (hard)
|
||||
│ Keep lines where player's access tier ∈ line.access
|
||||
│
|
||||
├─ Layer 2: SITUATION FILTER (context)
|
||||
│ Keep lines where any active situation ∈ line.situation
|
||||
│
|
||||
├─ Layer 3: TRUST FILTER (hard)
|
||||
│ Keep lines where player's trust ≥ line.trust
|
||||
│ (surface < real < secret)
|
||||
│
|
||||
└─ Layer 4: TOPIC + MOOD SELECTION (weighted)
|
||||
Score remaining lines by topic and mood match.
|
||||
Lines with no topic/mood tags get a neutral weight (always eligible,
|
||||
never boosted). Select from top-scored candidates with randomization.
|
||||
```
|
||||
|
||||
**Authoring implication:** Every line must pass Layers 1-3 to be eligible. Layers 1 and 3 are hard gates — get them wrong and the line is invisible. Layer 2 controls when the line fires. Layer 4 is a soft preference.
|
||||
|
||||
### 3.5 Complete dialogue example
|
||||
|
||||
```yaml
|
||||
# Kael Davan — dock worker at The Last Shift
|
||||
# Voice: direct, practical, short sentences, warm to trusted people
|
||||
location: the-last-shift
|
||||
role: dock-worker
|
||||
lines:
|
||||
- id: the-last-shift_d_001
|
||||
text: "Saved you a seat. Lera's got the spiced rice tonight."
|
||||
role: dock-worker
|
||||
access: [insider, peer]
|
||||
trust: surface
|
||||
situation: [bar_evening, social, arrival]
|
||||
mood: [fond]
|
||||
topic: [personal, colleague]
|
||||
tags: [kael, greeting, phase-1]
|
||||
|
||||
- id: the-last-shift_d_010
|
||||
text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [bar_evening, social]
|
||||
mood: [concerned]
|
||||
topic: [danger]
|
||||
tags: [kael, ring-ops, nils]
|
||||
|
||||
- id: the-last-shift_d_012
|
||||
text: "Lera knows more than she lets on. She won't say anything — but don't test it."
|
||||
role: dock-worker
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [bar_evening, social, alone]
|
||||
mood: [concerned]
|
||||
topic: [colleague, danger]
|
||||
tags: [kael, ring-ops, lera, caution]
|
||||
knowledge_grant:
|
||||
fact_id: knowledge.bar_ring_awareness
|
||||
confidence: suspects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Monologue Pool Format
|
||||
|
||||
### 4.1 File structure
|
||||
|
||||
```yaml
|
||||
character: {character} # required, hard partition (D-032)
|
||||
location: {location-slug} # required, or "general" for location-independent
|
||||
lines:
|
||||
- id: {line-id}
|
||||
text: "Internal monologue text."
|
||||
trigger: {trigger-type}
|
||||
prerequisites: # optional
|
||||
facts:
|
||||
- fact_id: {category.fact_id}
|
||||
min_confidence: {confidence-level}
|
||||
entity_attributes:
|
||||
- entity: {entity-ref}
|
||||
key: {attribute-key}
|
||||
value: {attribute-value}
|
||||
relationship:
|
||||
target: {entity-ref}
|
||||
state: {relationship-state}
|
||||
priority: {0-10} # optional, default 5
|
||||
cooldown: {ticks} # optional, minimum re-fire interval
|
||||
tags: [{freeform}, ...] # optional
|
||||
```
|
||||
|
||||
### 4.2 Header fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `character` | enum | YES | `smuggler` or `detective`. Hard partition per D-032 — pools are completely separate. The engine never crosses this boundary. |
|
||||
| `location` | string | YES | Location slug, or `general` for location-independent lines. Pattern: `^[a-z][a-z0-9-]*$\|^general$` |
|
||||
|
||||
### 4.3 Line fields
|
||||
|
||||
#### Core fields (required)
|
||||
|
||||
| Field | Type | Required | Validation | Description |
|
||||
|-------|------|----------|------------|-------------|
|
||||
| `id` | string | YES | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | Stable line identifier. See [Section 5: ID Format](#5-id-format). |
|
||||
| `text` | string | YES | 1-160 characters | The monologue line. 160-char max — authoring constraint to fit the monologue display without scrolling (established in `monologue-pool.schema.json`, not yet formalized as a decision). |
|
||||
| `trigger` | enum | YES | Single value | What causes this line to fire. Values: `enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit`. |
|
||||
|
||||
#### Prerequisites (optional, AND-combined)
|
||||
|
||||
All prerequisite conditions are AND-combined: every specified condition must be true for the line to be eligible.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `prerequisites` | object | Knowledge state gates. If omitted, the line has no prerequisites (always eligible given trigger). |
|
||||
| `prerequisites.facts` | list\<object\> | Fact-based gates. Each entry requires `fact_id` (string, `{category}.{fact_id}` format) and `min_confidence` (enum: `suspects`, `knows_of`, `knows_details`, `direct`). The player's confidence for the referenced fact must be ≥ the specified minimum. |
|
||||
| `prerequisites.entity_attributes` | list\<object\> | Entity attribute gates. Each entry requires `entity` (string, entity reference like `npc:kael-davan`), `key` (string, attribute name), `value` (string, expected value). |
|
||||
| `prerequisites.relationship` | object | Relationship state gate. Requires `target` (string, entity reference) and `state` (enum: `unknown`, `known`, `friendly`, `person_of_interest`, `hostile`). The player's relationship with the target must be at or beyond the specified state. |
|
||||
|
||||
**Prerequisite-to-KG mapping:**
|
||||
|
||||
| Prerequisite type | KG query | D-041 structure |
|
||||
|-------------------|----------|-----------------|
|
||||
| `facts[].fact_id` + `min_confidence` | Look up `FactId` in `BTreeMap<FactId, FactKnowledge>`, check `confidence ≥ min` | `FactKnowledge.confidence` (4-level: Suspects < KnowsOf < KnowsDetails < Direct) |
|
||||
| `entity_attributes[].entity` | Look up `StableId` via `EntityRegistry`, check attribute | `EntityKnowledge` attribute fields |
|
||||
| `relationship.target` + `state` | Look up entity in `BTreeMap<StableId, EntityKnowledge>`, check relationship state | `EntityKnowledge.relationship_state` |
|
||||
|
||||
#### Selection fields (optional)
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `priority` | integer | `5` | Selection priority, 0-10. Higher = more likely to fire when multiple lines match the same trigger + prerequisites. Priority 9-10 should be reserved for critical narrative moments. |
|
||||
| `cooldown` | integer | `0` | Minimum ticks before this line can fire again for the same player. At 10 tps (D-031), a cooldown of 600 = 1 game-minute. |
|
||||
| `tags` | list\<string\> | `[]` | Freeform tags. Not consumed by the selection engine — used for author organization and the line previewer. |
|
||||
|
||||
### 4.4 Monologue selection flow
|
||||
|
||||
```
|
||||
Trigger fires (e.g. enter_location)
|
||||
│
|
||||
├─ Character partition: select pool for current PC (D-032)
|
||||
│
|
||||
├─ Location filter: prefer location-specific pool, fall back to general
|
||||
│
|
||||
├─ Trigger filter: keep lines matching this trigger type
|
||||
│
|
||||
├─ Prerequisite gate: evaluate all prerequisites against KG state
|
||||
│ (AND-combined — all must pass)
|
||||
│
|
||||
├─ Cooldown check: exclude recently fired lines
|
||||
│
|
||||
└─ Priority-weighted selection: pick from eligible lines
|
||||
Higher priority = higher weight. Randomized among equal-priority.
|
||||
```
|
||||
|
||||
### 4.5 Complete monologue example
|
||||
|
||||
```yaml
|
||||
character: detective
|
||||
location: the-last-shift
|
||||
lines:
|
||||
# Basic atmospheric line — no prerequisites, any visit
|
||||
- id: the-last-shift_m_d_001
|
||||
text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant."
|
||||
trigger: enter_location
|
||||
tags: [arrival, atmospheric]
|
||||
|
||||
# Knowledge-gated observation — requires prior suspicion
|
||||
- id: the-last-shift_m_d_021
|
||||
text: "Sera left when Torek arrived. Second time. Different excuse. Same result."
|
||||
trigger: observe_anomaly
|
||||
prerequisites:
|
||||
facts:
|
||||
- fact_id: behavioral.sera_avoidance_pattern
|
||||
min_confidence: suspects
|
||||
priority: 7
|
||||
tags: [npc, sera, torek, tell, friend-arc]
|
||||
|
||||
# Relationship-gated line — requires person_of_interest status
|
||||
- id: the-last-shift_m_d_026
|
||||
text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth."
|
||||
trigger: observe_npc
|
||||
prerequisites:
|
||||
relationship:
|
||||
target: npc:sera-venn
|
||||
state: person_of_interest
|
||||
priority: 8
|
||||
tags: [npc, sera, contaminated-trust, friend-arc]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. ID Format
|
||||
|
||||
### 5.1 Pattern
|
||||
|
||||
```
|
||||
{location-slug}_{type}_{character?}_{sequence}
|
||||
```
|
||||
|
||||
| Segment | Format | Values | Example |
|
||||
|---------|--------|--------|---------|
|
||||
| `location-slug` | kebab-case | Matches location directory name, or `general` | `the-last-shift`, `general` |
|
||||
| `type` | single char | `d` = dialogue, `m` = monologue, `e` = environmental (future) | `d`, `m` |
|
||||
| `character` | single char | `s` = smuggler, `d` = detective. **Monologue only.** | `s`, `d` |
|
||||
| `sequence` | 3-digit zero-padded | `001`–`999` | `001`, `042` |
|
||||
|
||||
**General validation regex** (matches both dialogue and monologue IDs):
|
||||
|
||||
```
|
||||
^[a-z0-9-]+_(d|m)(_[a-z])?_\d{3}$
|
||||
```
|
||||
|
||||
- `[a-z0-9-]+` — location slug (kebab-case, at least one character)
|
||||
- `(d|m)` — pool type: `d` for dialogue, `m` for monologue
|
||||
- `(_[a-z])?` — optional character segment (monologue only): `_s` or `_d`
|
||||
- `\d{3}` — three-digit zero-padded sequence number
|
||||
|
||||
Use the pool-specific regexes in [Section 5.2](#52-regex-patterns) for strict per-type validation. This general regex is useful for quick format checks that accept either type.
|
||||
|
||||
> **D-035 deviation note:** D-035 specifies `{template}_{d|m|e}_{###}`. This spec refines that to `{location-slug}_{d|m}_{###}` (dialogue) and `{location-slug}_m_{s|d}_{###}` (monologue) for two reasons: (1) location-slug is more precise than template name and matches the directory hierarchy, and (2) monologue IDs include a character segment (`s`/`d`) to ensure uniqueness across the hard character partition (D-032). All existing authored content already uses this refined format. D-035 should be updated to reflect the implemented convention.
|
||||
|
||||
### 5.2 Regex patterns
|
||||
|
||||
| Pool type | Regex | Example |
|
||||
|-----------|-------|---------|
|
||||
| Dialogue | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | `the-last-shift_d_001` |
|
||||
| Monologue | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | `the-last-shift_m_d_021`, `general_m_s_003` |
|
||||
|
||||
### 5.3 Uniqueness scope
|
||||
|
||||
- IDs must be unique **within a single YAML file**.
|
||||
- IDs are **not required to be globally unique** — location slug + file path provides global uniqueness. The engine uses `(file_path, line_id)` as the composite key.
|
||||
- Sequence numbers need not be contiguous. Gaps are expected when lines are removed or reordered.
|
||||
|
||||
### 5.4 ID stability
|
||||
|
||||
IDs are **stable references**. Once assigned, a line ID should not change. Other systems (cooldown tracking, analytics, the line previewer) reference lines by ID. If a line's text changes, keep the ID. Only assign a new ID when creating a genuinely new line.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tag Enums Reference
|
||||
|
||||
All enum values are defined in `content/global/enums/` and validated by the JSON schemas in `content/_schema/`. This section is a quick reference — see the enum YAML files for full descriptions.
|
||||
|
||||
### 6.1 Access tiers (D-028 Layer 1)
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `public` | Available to anyone. Surface-level conversation. |
|
||||
| `insider` | Available to group members. Ring membership, established regulars. |
|
||||
| `authority` | Available to institutional figures. Commission agents, security. |
|
||||
| `peer` | Available to social equals with earned personal trust. |
|
||||
| `hostile` | Available when relationship has broken down. |
|
||||
|
||||
### 6.2 Trust tiers (D-028 Layer 3)
|
||||
|
||||
| Value | Gate level | Description |
|
||||
|-------|------------|-------------|
|
||||
| `surface` | Default | Safe, non-committal. What NPCs tell strangers. |
|
||||
| `real` | Earned | Genuine opinions, workplace complaints, personal concerns. |
|
||||
| `secret` | Highest | Information they'd normally hide. Ring involvement, vulnerabilities. |
|
||||
|
||||
### 6.3 Situations (D-028 Layer 2)
|
||||
|
||||
`arrival`, `shift_start`, `shift_end`, `shift_transition`, `bar_evening`, `night_shift`, `investigation`, `confrontation`, `social`, `alone`, `emergency`, `routine`, `observation`
|
||||
|
||||
13 values for v0.1. See `content/global/enums/situations.yaml` for full descriptions.
|
||||
|
||||
### 6.4 Topics (D-028 Layer 4)
|
||||
|
||||
`colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation`
|
||||
|
||||
9 values for v0.1. Note: `crime` is deliberately excluded — NPCs think of smuggling as `cargo` or `money`.
|
||||
|
||||
### 6.5 Moods (D-028 Layer 4)
|
||||
|
||||
`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`
|
||||
|
||||
8 values for v0.1.
|
||||
|
||||
### 6.6 Monologue triggers
|
||||
|
||||
`enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit`
|
||||
|
||||
9 values for v0.1.
|
||||
|
||||
### 6.7 Confidence levels (D-041)
|
||||
|
||||
| Value | Gate meaning | Maps to D-028 |
|
||||
|-------|-------------|----------------|
|
||||
| `suspects` | "Something's off" | Gates initial investigation |
|
||||
| `knows_of` | "X is involved in Y" | Gates `real` trust dialogue, peer access |
|
||||
| `knows_details` | Actionable detail | Gates confrontation, `secret` trust dialogue |
|
||||
| `direct` | Currently in LOS | Live position data, maximum rendering fidelity |
|
||||
|
||||
---
|
||||
|
||||
## 7. Authoring-Only Fields
|
||||
|
||||
These fields are consumed by the line previewer and authoring tools but **not by the runtime engine**. They may appear in any pool file.
|
||||
|
||||
| Field | Type | Scope | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| `dual_lens` | map | Per-line | Per-character authoring notes for mirror moments. Keys: `smuggler`, `detective`. Values: string notes about how each character interprets this line/moment differently. |
|
||||
| `notes` | string | Per-line | Free-text author notes. Context, intent, voice reminders. |
|
||||
|
||||
These fields are defined in the JSON schemas with `additionalProperties: false` — if you need to add them, the schemas must be updated first. Current schemas do not include these fields; they are reserved for a future schema update when the previewer authoring workflow matures.
|
||||
|
||||
---
|
||||
|
||||
## 8. Validation Rules
|
||||
|
||||
### 8.1 Structural validation (`make validate-content`)
|
||||
|
||||
The existing `make validate-content` target (#392) validates against JSON schemas. The following rules are enforced:
|
||||
|
||||
| Rule | Scope | Error level |
|
||||
|------|-------|-------------|
|
||||
| All required fields present | Both | ERROR |
|
||||
| `id` matches regex pattern | Both | ERROR |
|
||||
| `id` unique within file | Both | ERROR |
|
||||
| Enum values match defined sets | Both | ERROR |
|
||||
| `access` has ≥ 1 item | Dialogue | ERROR |
|
||||
| `situation` has ≥ 1 item | Dialogue | ERROR |
|
||||
| `text` is non-empty | Both | ERROR |
|
||||
| `text` ≤ 160 chars | Monologue | ERROR |
|
||||
| `character` matches parent directory | Monologue | ERROR |
|
||||
| `location` matches parent directory | Both | ERROR |
|
||||
| `priority` is 0-10 | Monologue | ERROR |
|
||||
| `knowledge_grant.fact_id` references existing fact | Dialogue | WARNING |
|
||||
| `prerequisites.facts[].fact_id` references existing fact | Monologue | WARNING |
|
||||
| Lists have `uniqueItems` | Both | ERROR |
|
||||
|
||||
### 8.2 Content-level validation (line previewer, future)
|
||||
|
||||
These are not yet enforced by tooling but are authoring guidelines:
|
||||
|
||||
- Every template role should have ≥ 5 `surface` trust lines at `public` access (baseline conversation)
|
||||
- Every location should have ≥ 3 monologue lines per character for `enter_location` trigger (first-visit coverage)
|
||||
- Lines tagged with `phase-5` (contaminated trust) should have corresponding `phase-1` baseline lines
|
||||
- `knowledge_grant` confidence should not exceed `knows_of` from a single dialogue line (hearing one line shouldn't grant `knows_details`)
|
||||
- Named NPC files should have lines across ≥ 2 trust tiers
|
||||
|
||||
---
|
||||
|
||||
## 9. Rust Loader Interface
|
||||
|
||||
For #326 (YAML content loader), the engine parses these files into in-memory structures. This section defines the target API — not the implementation.
|
||||
|
||||
### 9.1 Core types
|
||||
|
||||
```rust
|
||||
/// A loaded dialogue line, fully parsed and validated.
|
||||
struct DialogueLine {
|
||||
id: LineId,
|
||||
text: String,
|
||||
role: RoleSlug,
|
||||
access: Vec<AccessTier>, // D-028 Layer 1
|
||||
trust: TrustTier, // D-028 Layer 3
|
||||
situation: Vec<Situation>, // D-028 Layer 2
|
||||
topic: Vec<Topic>, // D-028 Layer 4
|
||||
mood: Vec<Mood>, // D-028 Layer 4
|
||||
tags: Vec<String>,
|
||||
knowledge_grant: Option<KnowledgeGrant>,
|
||||
}
|
||||
|
||||
/// A loaded monologue line, fully parsed and validated.
|
||||
struct MonologueLine {
|
||||
id: LineId,
|
||||
text: String, // ≤ 160 chars
|
||||
trigger: Trigger,
|
||||
prerequisites: Option<Prerequisites>,
|
||||
priority: u8, // 0-10, default 5
|
||||
cooldown: u32, // ticks, default 0
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pool container — one per file loaded.
|
||||
struct DialoguePool {
|
||||
location: LocationSlug,
|
||||
role: RoleSlug,
|
||||
lines: Vec<DialogueLine>,
|
||||
}
|
||||
|
||||
struct MonologuePool {
|
||||
character: Character, // smuggler | detective
|
||||
location: LocationSlug, // or "general"
|
||||
lines: Vec<MonologueLine>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Query API
|
||||
|
||||
```rust
|
||||
/// Query dialogue lines through the 4-layer pipeline.
|
||||
fn query_dialogue(
|
||||
pool: &DialoguePool,
|
||||
player_access: AccessTier,
|
||||
active_situations: &[Situation],
|
||||
player_trust: TrustTier,
|
||||
npc_topics: &[Topic], // weighted preference, not hard filter
|
||||
npc_mood: &[Mood], // weighted preference, not hard filter
|
||||
) -> Vec<&DialogueLine>;
|
||||
|
||||
/// Query monologue lines for a trigger event.
|
||||
fn query_monologue(
|
||||
pools: &[MonologuePool], // all pools for current character + location
|
||||
character: Character,
|
||||
trigger: Trigger,
|
||||
knowledge: &KnowledgeGraph, // D-041
|
||||
fired_cooldowns: &BTreeMap<LineId, Tick>, // BTreeMap per D-041
|
||||
) -> Vec<&MonologueLine>;
|
||||
```
|
||||
|
||||
### 9.3 Indexing strategy
|
||||
|
||||
Per D-041 determinism requirements, all internal maps use `BTreeMap`:
|
||||
|
||||
- **Primary index:** `BTreeMap<(LocationSlug, RoleSlug), DialoguePool>` — dialogue pools by location + role
|
||||
- **Access pre-filter:** Lines within each pool pre-sorted by access tier for O(1) hard filter
|
||||
- **Monologue index:** `BTreeMap<(Character, LocationSlug), Vec<MonologuePool>>` — monologue pools by character + location
|
||||
- **Trigger index:** Within each monologue pool, lines grouped by trigger type for fast lookup
|
||||
|
||||
---
|
||||
|
||||
## 10. Content Volume Estimates
|
||||
|
||||
Per D-028, target line counts for v0.1 vertical slice (Sova Transit District):
|
||||
|
||||
| Content type | Per template role | Per location | District total |
|
||||
|--------------|-------------------|--------------|----------------|
|
||||
| Dialogue lines | 165-210 authored | ~500-700 | ~2,000-3,000 |
|
||||
| Monologue lines (per character) | — | 20-40 | ~100-200 |
|
||||
| Generation-expanded (4x) | 660-840 | ~2,000-2,800 | ~8,000-12,000 |
|
||||
|
||||
The generation pass (write 10, generate 40) is a future pipeline step — authored YAML files contain only human-written lines.
|
||||
|
||||
---
|
||||
|
||||
## 11. Schema Files
|
||||
|
||||
The canonical JSON Schema files that validate this format:
|
||||
|
||||
| Schema | Path | Validates |
|
||||
|--------|------|-----------|
|
||||
| Dialogue pool | `content/_schema/dialogue-pool.schema.json` | `dialogue/**/*.yaml` |
|
||||
| Monologue pool | `content/_schema/monologue-pool.schema.json` | `monologue/**/*.yaml` |
|
||||
| Fact catalog | `content/_schema/fact-catalog.schema.json` | `global/knowledge/*.yaml` |
|
||||
|
||||
These schemas are the machine-enforceable subset of this specification. This document is the authoritative reference; the schemas enforce the structural rules.
|
||||
Reference in New Issue
Block a user