Files
settled-reach/docs/workshops/v01-content-scoping/round1-dudley.md
T
jpmschweitzerandClaude Opus 4.6 3944b4e79f docs(workshops): archive v0.1 content scoping workshop (2 rounds + closing)
Scoped the vertical slice: 16 EntityKnowledge keys, v0.1 mechanical
NPC mapping, YAML content format, 7-verb interaction model, server-
authoritative pause, 38 tickets created across copy/server/client/ci.
20 decisions (D-042 through D-061). 8 agents, 2 rounds + closing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:19:05 +01:00

32 KiB

Round 1 — Dudley (Server Developer)

Topic 2: NPC Architecture — Server-Side Entity Data & ECS Design

Current State

The server already has a basic NPC component set from D-024 (server/src/npc/mod.rs): Npc marker, Want, Secret, Relationships, ToleranceThreshold, DailyRoutine, InformationInventory, Contentment, PersonalityTraits, TellSystem, SkillSet, CombatCapability. These implement the 10-axis model but are string-heavy placeholders. They don't yet encode the three-system NPC model (pattern + motivation + composition).

The knowledge graph (server/src/knowledge/) is production-ready for Sprint 2 scope: KnowledgeGraph component with BTreeMap storage, EntityKnowledge/FactKnowledge entries, confidence hierarchy, decay system, event-driven updates via KnowledgeEventQueue.

What Each Pattern Requires (System A)

Patterns define what the character means to the story. Most pattern behavior emerges from existing D-024 axes plus a pattern tag and pattern-specific state. The simulation doesn't need a unique system per pattern — it needs the pattern tag to gate content selection and a small amount of pattern-specific state.

/// System A: Thematic pattern tag.
/// Drives content selection (which monologue pool, which dialogue options).
/// The pattern itself is behavioral coloring on top of D-024 axes,
/// not a separate simulation system.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ThematicPattern {
    Friend,
    Mirror,
    Anchor,
    Ghost,
    Catalyst,
    Threshold,
    Remnant,
    System,
    Nobody,
}

Pattern-specific server-side state needed for v0.1:

Pattern Extra State Beyond D-024 v0.1?
FRIEND FriendArc component — phase tracking, contradiction fact IDs, tell progression stage, discovery state YES (Kael, Sera)
MIRROR None — MIRROR power is absence of contradiction. Normal D-024 axes, no secret, no hidden state. YES (Naia Tamm)
ANCHOR None — contentment axis (already exists) IS the anchor mechanic. Maybe
GHOST GhostInfluence — list of entities this ghost controls, communication channel. Not physically present. NO (v0.2+)
CATALYST CatalystTrigger — arrival condition, disruption event chain. NO (v0.2+)
THRESHOLD GateCondition — what the player needs to pass. Maps to knowledge prerequisites. Maybe
REMNANT None — InformationInventory with deep historical facts suffices. Maybe
SYSTEM InstitutionalRole — faction affiliation, procedure set, authority level. YES (Torek)
NOBODY NobodyState — promotion stage (Nobody/Noticed/Recognized/Known/Invested), interaction counter, dormant pattern+motivation. DEFERRED per workshop brief

THE FRIEND is the only pattern that requires a dedicated component in v0.1:

/// THE FRIEND arc state (D-034).
/// Tracks the emotional progression that exercises every content pipeline.
/// One per FRIEND NPC (Kael for smuggler, Sera for detective).
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct FriendArc {
    /// Which playable character this FRIEND belongs to.
    /// The simulation doesn't know "the player" — it knows character IDs.
    pub bonded_character: StableId,
    /// Current arc phase. Advances based on knowledge events + interaction count.
    pub phase: FriendPhase,
    /// FactIds that constitute the contradiction (e.g., "kael.secret_meetings").
    /// When the bonded character learns these, phase shifts to Doubt.
    pub contradiction_facts: Vec<FactId>,
    /// Tell progression: index into the tell sequence.
    /// Tells become more visible as phase advances.
    pub tell_stage: u8,
    /// Has the bonded character directly observed the contradiction?
    pub contradiction_discovered: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FriendPhase {
    /// Default — warmth, trust-building. "We're in this together."
    Warmth,
    /// Player has hints — something is off. Tells become visible.
    Trust,
    /// Contradiction evidence accumulating. Monologue turns questioning.
    Doubt,
    /// Direct confrontation or discovery. Relationship fractures.
    Conflict,
}

What Each Motivation Requires (System B)

Motivations define what the character does in gameplay. Like patterns, most behavior comes from D-024 axes. The motivation tag gates which interaction options the server offers and how the NPC responds in dialogue selection.

/// System B: Functional motivation tag.
/// Drives NPC behavior in interactions — what they want from the player,
/// what information they'll share, how they respond to pressure.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FunctionalMotivation {
    Handler,
    Witness,
    Turncoat,
    Civilian,
    Operator,
    Skeptic,
}

Motivation-specific state:

Motivation Extra State v0.1 Notes
HANDLER HandlerState — which entities they manage, pressure level, directive queue YES — Voss manages the ring
WITNESS WitnessKnowledge — specific observed FactIds, willingness to share, fear level YES — key investigation mechanic
TURNCOAT LoyaltyState — current allegiance, flip conditions, which side they're reporting to Maybe (depends on NPC mapping)
CIVILIAN None — the absence of special state IS the civilian mechanic. Normal D-024 axes. YES — most Tier 3 NPCs
OPERATOR OperatorGoal — current objective, resources managed, competence level YES — Kael, Lera
SKEPTIC SkepticTarget — what institution/system they doubt, what questions they ask Maybe

Most motivations don't need dedicated components. The motivation tag + existing D-024 axes + content tagging (D-035) handle the behavioral differentiation. The server's job is to expose the tag for content selection, not to simulate each motivation as a separate state machine.

Proposed ECS Component Design (v0.1 Complete)

Here's the full component bundle for spawning an NPC entity. Components are split into identity (always present), behavioral (from D-024), pattern/motivation (System A+B), and optional:

/// Identity components — every NPC has these
pub struct NpcIdentity {
    pub stable_id: StableId,         // server-side, never changes
    pub canonical_id: String,        // content-addressing key ("sova.kael_davan")
    pub display_name: String,        // "Kael Davan"
    pub short_name: String,          // "Kael" — used in social contexts (D-036)
}

/// Content tier — determines simulation depth (D-026 + D-029)
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContentTier {
    Tier1,  // Production-level (FRIEND, MIRROR) — full sim, all axes active
    Tier2,  // Templated with variation — full sim, most axes active
    Tier3,  // Procedural filler — background sim, minimal axes
}

/// Access tier map — D-028 Layer 1.
/// Determines which dialogue access tiers this NPC responds to
/// for each interacting character.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct AccessTierMap {
    /// Default access tier for unknown characters
    pub default_access: AccessTier,
    /// Per-entity overrides (e.g., Kael treats smuggler as "insider")
    pub overrides: BTreeMap<StableId, AccessTier>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AccessTier {
    Public,
    Insider,
    Authority,
    Peer,
    Hostile,
}

/// Trust level for gossip gating — D-028 Layer 3.
/// Per-relationship trust determines disclosure tier.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct TrustLevels {
    /// Per-entity trust level. Absent = surface only.
    pub levels: BTreeMap<StableId, TrustTier>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum TrustTier {
    Surface = 0,
    Real = 1,
    Secret = 2,
}

/// Triangle membership — which social triangles this NPC participates in.
/// Used by the server to determine which events ripple through which NPCs.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct TriangleMembership {
    pub triangles: Vec<TriangleRef>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriangleRef {
    pub triangle_id: String,     // "hub_power", "dock_loyalty", etc.
    pub role: String,            // NPC's role within this triangle
}

/// NPC routine — schedule-based movement (D-031 day phases)
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct NpcRoutine {
    pub schedule: Vec<RoutineEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineEntry {
    pub phase: DayPhase,
    pub location: String,        // canonical location ID ("sova.terminal", "sova.last_shift")
    pub tile: TilePosition,      // exact position within location
    pub activity: String,        // "working", "drinking", "patrolling" — drives animation + availability
}

/// Mood state — drives D-035 mood tag selection for dialogue lines
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct MoodState {
    pub current_mood: Mood,
    pub stress: f32,             // 0.0-1.0, accumulated from events
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mood {
    Neutral,
    Friendly,
    Guarded,
    Anxious,
    Hostile,
    Distracted,
    Resigned,
    Amused,
}

Full NPC entity bundle for spawning:

/// Everything needed to spawn a v0.1 NPC entity.
/// Not all components required — ContentTier determines which are populated.
struct NpcBundle {
    // Always present:
    npc: Npc,
    identity: NpcIdentity,
    content_tier: ContentTier,
    pattern: ThematicPattern,
    motivation: FunctionalMotivation,
    position: TilePosition,
    knowledge: KnowledgeGraph,
    routine: NpcRoutine,

    // Tier 1-2:
    want: Want,
    secret: Secret,
    relationships: Relationships,
    tolerance: ToleranceThreshold,
    information: InformationInventory,
    contentment: Contentment,
    personality: PersonalityTraits,
    tells: TellSystem,
    skills: SkillSet,
    access_tiers: AccessTierMap,
    trust_levels: TrustLevels,
    mood: MoodState,
    triangles: TriangleMembership,

    // Pattern-specific (optional):
    friend_arc: Option<FriendArc>,

    // Combat (optional, D-024):
    combat: Option<CombatCapability>,
}

Key Design Principle

The simulation guarantees: pattern and motivation are tags for content selection, not unique simulation subsystems. The server doesn't run 9 different pattern AIs. It runs one NPC tick loop that checks the pattern tag when selecting dialogue, scheduling tell visibility, and evaluating arc progression. THE FRIEND is the sole exception in v0.1 because its arc progression is load-bearing for the vertical slice.


Topic 3: Content Loader — Load Sequence & NPC Spawning

What the Server Content Loader Needs

The server needs to transform content files into ECS entities with components. State consistency requires a defined load sequence with validation at each stage.

Server content requirements:

  1. District definition — map dimensions, chunk layout, walkability grid, z-levels, location boundaries
  2. NPC profiles — identity, axes, pattern, motivation, routine, relationships, knowledge, access tiers
  3. FactId catalog — all valid fact identifiers with metadata (what category, what it means)
  4. Triangle definitions — which NPCs, what roles, escalation conditions
  5. Dialogue line pools — tagged lines per D-035 (server does selection, client does display)
  6. Monologue pools — tagged lines per D-035 (separate per character, D-032)
  7. Contraband definitions — what items exist, manifest templates for ring operations

What the server does NOT need from content files:

  • Visual assets (sprites, tilesets) — client only
  • Audio assets — client only
  • UI text that isn't dialogue/monologue — client only
  • Wiki prose — authoring format only

Proposed Load Sequence

PHASE 1: VALIDATE
  Read content manifest (content/manifest.ron)
  Validate all referenced files exist
  Validate schema compliance (FactId catalog, NPC profiles, etc.)
  FAIL FAST if any validation error — do not partially load

PHASE 2: GLOBAL RESOURCES
  Load FactId catalog → FactCatalog resource
  Load contraband definitions → ContrabandDefs resource
  Load triangle definitions → TriangleDefs resource
  Load dialogue pools → DialoguePool resource
  Load monologue pools → MonologuePool resource (per-character, D-032)

PHASE 3: MAP
  Load district walkability grid → WalkabilityMap resource (already exists)
  Load location boundaries → LocationMap resource (new)
  Load interactable object positions → spawn Object entities

PHASE 4: ENTITIES
  For each NPC profile:
    Allocate StableId via EntityRegistry
    Spawn entity with NpcBundle components (derived from profile)
    Initialize KnowledgeGraph with background facts
    Set initial position from routine (current day phase)
  For each playable character:
    Spawn with PlayerCharacter marker + full component set
    Initialize KnowledgeGraph with character-specific background

PHASE 5: RELATIONSHIPS
  Wire up Relationships components (now that all StableIds exist)
  Wire up AccessTierMap overrides (per-entity access)
  Wire up TrustLevels (per-entity trust)
  Wire up FriendArc.bonded_character
  Wire up TriangleMembership references

PHASE 6: VERIFY
  Assert all StableId references resolve
  Assert all routine locations exist in LocationMap
  Assert all FactIds in NPC knowledge exist in FactCatalog
  Assert FriendArc NPCs exist and are bonded to valid characters
  Log load summary: N entities, M facts, K dialogue lines

Phase ordering matters. Phase 5 requires all entities from Phase 4 to exist (StableId resolution). Phase 3 must complete before Phase 4 (NPCs need valid positions). Phase 2 must complete before Phase 4 (NPCs reference FactIds and dialogue pools).

How an NPC Profile Becomes a Spawned Entity

Assuming RON format (Tyre's call on format — I need structured, typed data, not free-form YAML):

// content/districts/sova/npcs/kael_davan.ron
NpcProfile(
    canonical_id: "sova.kael_davan",
    display_name: "Kael Davan",
    short_name: "Kael",
    content_tier: Tier1,
    pattern: Friend,
    motivation: Operator,

    // D-024 axes
    want: "Exit the ring, protect partner Naia. Wants a clean life.",
    secret: "Meeting unknown contact in restricted corridor. Trying to leave the ring.",
    tolerance_threshold: 0.65,
    contentment: 0.4,
    personality: ["loyal", "cautious", "practical"],
    skills: ["cargo_handling", "logistics", "forging_manifests"],
    combat_trained: false,

    // Routine (D-031 day phases)
    routine: [
        (phase: Morning, location: "sova.terminal", tile: (42, 18, 0), activity: "working"),
        (phase: Afternoon, location: "sova.terminal", tile: (45, 20, 0), activity: "working"),
        (phase: Evening, location: "sova.last_shift", tile: (80, 55, 0), activity: "drinking"),
        (phase: Night, location: "sova.residential", tile: (30, 70, 0), activity: "sleeping"),
    ],

    // Knowledge — what Kael knows at game start
    background_facts: [
        ("contraband.ring_exists", KnowsDetails),
        ("contraband.lattice_components", KnowsDetails),
        ("npc.voss.is_handler", KnowsDetails),
        ("npc.naia.partner", KnowsDetails),
        ("location.restricted_corridor", KnowsOf),
    ],

    // Relationships (resolved to StableIds at load time via canonical_id lookup)
    relationships: [
        (target: "sova.naia_tamm", kind: "partner", trust: Real),
        (target: "sova.voss", kind: "handler", trust: Real),
        (target: "sova.drin", kind: "colleague", trust: Surface),
    ],

    // Access tiers — who can talk to Kael at what level
    access: (
        default: Public,
        overrides: [
            ("sova.smuggler", Insider),   // smuggler PC gets insider access
            ("sova.detective", Public),    // detective is just another face
            ("sova.voss", Peer),
        ],
    ),

    // FRIEND-specific (only if pattern == Friend)
    friend_arc: Some((
        bonded_character: "sova.smuggler",
        initial_phase: Warmth,
        contradiction_facts: [
            "kael.secret_meetings",
            "kael.restricted_corridor_visits",
        ],
    )),

    // Tell system (D-034)
    tells: [
        "Checks comm device when alone — frequency increases as stress rises",
        "Avoids eye contact with Voss after receiving new instructions",
        "Takes longer routes to avoid restricted corridor during busy hours",
    ],
)

Load transformation:

  1. Parser reads NpcProfile from RON file
  2. Loader allocates StableId via EntityRegistry::register()
  3. Canonical ID → StableId mapping stored in ContentAddressMap resource
  4. Components constructed from profile fields — string references to other NPCs deferred to Phase 5
  5. KnowledgeGraph::with_background() called with the background_facts list
  6. Entity spawned with full component bundle
  7. Phase 5: relationships, access.overrides, trust_levels, and friend_arc.bonded_character resolved using ContentAddressMap canonical_id → StableId lookup

Resources the Loader Creates

/// Maps canonical content IDs to runtime StableIds.
/// Created during Phase 4, used in Phase 5 for reference resolution.
#[derive(Resource)]
pub struct ContentAddressMap {
    pub by_canonical: BTreeMap<String, StableId>,
}

/// All valid FactIds in this district + global facts.
/// Used for validation and monologue prerequisite checking.
#[derive(Resource)]
pub struct FactCatalog {
    pub facts: BTreeMap<FactId, FactMetadata>,
}

pub struct FactMetadata {
    pub category: String,
    pub description: String,
    pub discoverable_by: Vec<String>,  // which characters can learn this
}

/// Location boundaries within the district.
/// Maps location IDs to tile regions for proximity triggers.
#[derive(Resource)]
pub struct LocationMap {
    pub locations: BTreeMap<String, LocationDef>,
}

pub struct LocationDef {
    pub canonical_id: String,
    pub bounds: Vec<TilePosition>,  // bounding polygon or rect
    pub z_level: i32,
}

/// Tagged dialogue line pool (D-028, D-035).
/// Server selects lines; client displays them.
#[derive(Resource)]
pub struct DialoguePool {
    pub lines: Vec<DialogueLine>,
    // Indices by role, access, trust for fast filtering
    pub by_role: BTreeMap<String, Vec<usize>>,
}

/// Tagged monologue line pool (D-032, D-035).
/// Separate per playable character — hard partition.
#[derive(Resource)]
pub struct MonologuePool {
    pub smuggler: Vec<MonologueLine>,
    pub detective: Vec<MonologueLine>,
}

Topic 4: Interaction Model — Server Events & Availability

Minimum Server Events for v0.1

The simulation needs to support four interaction types: approach + dialogue, monologue triggers, object examination, and overhearing. Each requires specific server-side events.

/// Interaction events flowing through the server.
/// Produced by interaction systems, consumed by dialogue/monologue/knowledge systems.
#[derive(Debug, Clone)]
pub enum InteractionEvent {
    /// Player entered proximity range of an NPC (triggers interaction availability).
    /// Range: configurable, default 3 tiles (Manhattan distance).
    ProximityEnter {
        character: Entity,
        target: Entity,
        distance: u32,
    },

    /// Player left proximity range of an NPC.
    ProximityExit {
        character: Entity,
        target: Entity,
    },

    /// Player requested interaction with target entity.
    /// Server validates: is target in range? Is target interactable? What options?
    InteractionRequest {
        character: Entity,
        target: Entity,
        tick: u64,
    },

    /// Server response: available interaction options for this target.
    /// Sent to client via ObserverSnapshot extension.
    InteractionOptions {
        character: Entity,
        target: Entity,
        options: Vec<InteractionOption>,
    },

    /// Player selected a dialogue topic/line.
    DialogueSelect {
        character: Entity,
        npc: Entity,
        topic: String,
        tick: u64,
    },

    /// Server selected a dialogue response (D-035 pipeline output).
    DialogueResponse {
        npc: Entity,
        line_id: String,
        text: String,
        // Knowledge updates that result from this line being spoken
        knowledge_grants: Vec<KnowledgeGrant>,
    },

    /// Conversation ended (player walked away, topic exhausted, NPC terminated).
    DialogueEnd {
        character: Entity,
        npc: Entity,
        reason: DialogueEndReason,
    },

    /// Monologue triggered by game state (D-035 trigger types).
    MonologueTrigger {
        character: Entity,
        trigger_type: MonologueTriggerType,
        context: MonologueContext,
    },

    /// Player examined an interactable object.
    ExamineObject {
        character: Entity,
        object: Entity,
        tick: u64,
    },

    /// Overheard conversation fragment (proximity-based, D-018 sound model).
    OverheardFragment {
        listener: Entity,
        speakers: Vec<Entity>,
        content_hint: String,  // not full text — what the listener picked up
        knowledge_grants: Vec<KnowledgeGrant>,
    },
}

#[derive(Debug, Clone)]
pub struct InteractionOption {
    pub kind: InteractionKind,
    pub label: String,           // displayed to player
    pub enabled: bool,           // false = visible but greyed out (shows the player what's possible)
    pub disabled_reason: Option<String>,  // why it's greyed out
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractionKind {
    Talk,           // initiate dialogue
    Examine,        // look more closely
    Overhear,       // listen to nearby conversation (passive — may not need explicit action)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogueEndReason {
    PlayerLeft,       // walked out of range
    TopicExhausted,   // no more lines available
    NpcTerminated,    // NPC ended conversation (mood, tolerance)
    Interrupted,      // another event broke the conversation
}

/// What knowledge a dialogue line grants when spoken.
#[derive(Debug, Clone)]
pub struct KnowledgeGrant {
    pub grant_type: KnowledgeGrantType,
}

#[derive(Debug, Clone)]
pub enum KnowledgeGrantType {
    LearnFact { fact_id: FactId, confidence: KnowledgeConfidence },
    LearnAttribute { target: StableId, key: String, value: String },
    UpdateRelationship { target: StableId, new_state: RelationshipState },
}

/// Monologue trigger types (D-035 `trigger` enum).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MonologueTriggerType {
    EnterLocation,
    ObserveNpc,
    HearSound,
    ObserveAnomaly,
    PostConversation,
    DiscoverEvidence,
    WitnessInteraction,
    TimeIdle,
    ReturnVisit,
}

/// Context passed with monologue triggers for line selection.
#[derive(Debug, Clone)]
pub struct MonologueContext {
    pub location: Option<String>,
    pub observed_npc: Option<StableId>,
    pub relevant_facts: Vec<FactId>,
}

Proximity Trigger System

/// System: check proximity between player character and NPCs each tick.
/// Emits ProximityEnter/ProximityExit events when distance crosses threshold.
/// v0.1 uses Manhattan distance on same z-level. Simple, deterministic.
const INTERACTION_RANGE: u32 = 3;

fn check_proximity(
    player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
    npc_query: Query<(Entity, &TilePosition), With<Npc>>,
    mut proximity_state: ResMut<ProximityState>,
    mut events: ResMut<InteractionEventQueue>,
    time: Res<SimulationTime>,
) {
    let Ok((player_entity, player_pos)) = player_query.single() else { return };

    for (npc_entity, npc_pos) in npc_query.iter() {
        let distance = player_pos.manhattan_distance(npc_pos);
        let in_range = distance.is_some_and(|d| d <= INTERACTION_RANGE);
        let was_in_range = proximity_state.is_near(player_entity, npc_entity);

        if in_range && !was_in_range {
            proximity_state.set_near(player_entity, npc_entity);
            events.push(InteractionEvent::ProximityEnter {
                character: player_entity,
                target: npc_entity,
                distance: distance.unwrap(),
            });
        } else if !in_range && was_in_range {
            proximity_state.set_far(player_entity, npc_entity);
            events.push(InteractionEvent::ProximityExit {
                character: player_entity,
                target: npc_entity,
            });
        }
    }
}

/// Tracks which entities are currently in proximity.
/// BTreeSet for deterministic iteration.
#[derive(Resource, Default)]
pub struct ProximityState {
    near_pairs: BTreeSet<(Entity, Entity)>,
}

How the Server Determines Available Interaction Options

When the player sends PlayerAction::Interact, the server runs a pipeline:

1. RANGE CHECK
   Is there an NPC or interactable object within INTERACTION_RANGE?
   If multiple targets, pick closest (Manhattan distance tiebreaker: lowest StableId).

2. AVAILABILITY CHECK (per target)
   Is the NPC in an interactable activity? (not sleeping, not in combat)
   What's the NPC's current mood? (Hostile NPCs may refuse)
   Is the NPC already in conversation with someone else?

3. ACCESS TIER RESOLUTION
   Look up AccessTierMap for the interacting character.
   Determine: Public / Insider / Authority / Peer / Hostile access level.

4. OPTION GENERATION
   Talk: always available if NPC is interactable + not hostile
   Examine: always available for objects, available for NPCs if in LOS
   (Overhear is passive — triggered by proximity to NPC-NPC conversations, not by player action)

5. RESPONSE
   Pack InteractionOptions into ObserverSnapshot extension.
   Client renders the context-sensitive prompt.

The critical insight: the server never sends dialogue text unprompted. The flow is:

  • Server tells client "you can Talk to Kael" (via snapshot)
  • Client shows interaction prompt
  • Player selects "Talk"
  • Server runs dialogue selection pipeline (D-035: access filter → situation filter → trust filter → mood/topic weighted selection)
  • Server sends selected line + any knowledge grants
  • Client displays line, processes knowledge grants

This keeps the server authoritative over what you can learn and when.

What Goes into the ObserverSnapshot (v3)

The ObserverSnapshot needs new fields for interaction support:

pub struct ObserverSnapshot {
    // ... existing v2 fields ...

    /// NPCs currently in interaction range with available options.
    pub nearby_interactions: Vec<NearbyInteraction>,

    /// Active dialogue state (if in conversation).
    pub active_dialogue: Option<ActiveDialogue>,

    /// Monologue lines triggered this tick.
    pub monologue_lines: Vec<MonologueLine>,

    /// Overheard fragments (proximity-based).
    pub overheard: Vec<OverheardLine>,
}

pub struct NearbyInteraction {
    pub entity_id: u64,      // StableId
    pub display_name: String,
    pub options: Vec<InteractionOption>,
}

pub struct ActiveDialogue {
    pub npc_entity_id: u64,
    pub npc_name: String,
    pub current_line: Option<DialogueLineDisplay>,
    pub available_topics: Vec<String>,
}

pub struct DialogueLineDisplay {
    pub line_id: String,
    pub text: String,
    pub mood: String,
}

pub struct MonologueLine {
    pub line_id: String,
    pub text: String,
    pub trigger: MonologueTriggerType,
}

pub struct OverheardLine {
    pub speaker_id: u64,
    pub fragment: String,
}

Topic 5: Server Features Blocked by Missing Content

What I Can Build Right Now (No Content Dependency)

These server systems are purely structural — they need the schema but not the data:

System Status Blocked?
Content loader framework (Phase 1-6 skeleton) Can build NOT blocked — test with fixture data
Proximity trigger system Can build NOT blocked
Interaction event pipeline Can build NOT blocked
Dialogue selection pipeline (D-035 tag filtering) Can build NOT blocked — test with synthetic tagged lines
Monologue trigger system Can build NOT blocked — test with synthetic lines
Monologue prerequisite checker Already built (KnowledgeGraph.fact_at_least) Done
NPC routine scheduler (day phase → position) Can build NOT blocked
Knowledge decay Already built Done
FriendArc phase transition logic Can build NOT blocked — trigger conditions are structural

What I Cannot Build Without Content

Blocked Feature What Content Is Missing Who Produces It
Actual NPC spawning NPC profiles in RON/YAML format. Need all 17 Sova NPCs with axes, routine, relationships, background knowledge, access tiers. Paula (profiles) + Tyre (format)
Dialogue selection testing with real lines Tagged dialogue line pools per D-035. Need at minimum THE FRIEND lines (Kael: ~70-100, Sera: ~70-100) and a baseline pool for Tier 2-3 NPCs. Mellanie (authored lines)
Monologue testing with real lines Tagged monologue pools per D-032/D-035. Need smuggler pool + detective pool, minimum viable before repetition. Mellanie (authored lines)
NPC routine pathfinding District map with walkability data and location definitions. Need the Sova Transit District spatial layout. Tyre (map format) + Stig (Godot map editor?)
Overheard conversations NPC-to-NPC conversation scripts or generation rules. Which NPCs talk to each other, about what, when. Paula (scripts) + Gestalt (rules)
FactId catalog Complete list of discoverable facts for Sova, categorized. Currently wiki has some fact vocabulary but not a machine-readable catalog. Gestalt (catalog design) + Paula (fact content)
Triangle escalation logic Formal escalation conditions for each triangle. "When X learns Y, Z happens." Currently prose descriptions only. Gestalt (conditions) + Paula (narrative)
Contraband manifest data Manifest templates, discrepancy patterns, cargo types. Detective investigation vectors need concrete data. Miri (setting) + Gestalt (mechanics)

Priority Order for Unblocking

  1. FactId catalog — blocks everything knowledge-related. I need the schema NOW, content can come incrementally.
  2. NPC profiles in structured format — blocks spawning. Even partial profiles unblock integration testing.
  3. District spatial layout — blocks routine scheduler, proximity system integration testing.
  4. Dialogue line pools — blocks dialogue pipeline integration. Synthetic data works for unit tests but not for end-to-end.
  5. Monologue line pools — blocks monologue pipeline integration. Same story.

What I Propose to Do in Parallel

While content is being produced, I'll build with fixture/synthetic data:

  • Fixture NPC profiles: hand-write 3-4 test profiles (Kael, Sera, one Tier 2, one Tier 3) in whatever format Tyre proposes. These become the "reference implementation" that real profiles must match.
  • Synthetic dialogue pool: ~20-30 tagged lines exercising all D-035 tags. Enough to test the selection pipeline, not enough to feel like a game.
  • Synthetic monologue pool: ~10-15 lines per character with varied trigger types and prerequisites.
  • Test district map: 50x50 walkable area with 3 blocked-out locations (terminal, bar, corridors).

This lets me build and test every system end-to-end. When real content arrives, it drops into the same loader — the schema is the contract.

One Dependency I Need Resolved

The Interact action is currently a no-op (server/src/simulation/input.rs:86). To build the interaction pipeline, I need to extend PlayerAction with:

pub enum PlayerAction {
    // ... existing variants ...
    Interact,                          // becomes: interact with nearest target
    InteractWith(u64),                 // interact with specific entity by StableId
    SelectDialogueTopic(String),       // choose a topic in active dialogue
    EndDialogue,                       // walk away from conversation
}

This is a protocol change (ObserverSnapshot v2 → v3). Stig needs to know about the new snapshot fields. I need Tyre's sign-off on the protocol evolution strategy.