Files
settled-reach/docs/workshops/v01-content-scoping/round2-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 2 — Dudley (Server Developer)

1. YAML Acceptance — Loader Adaptation

Accepted. YAML is the content format for v0.1. The lead has spoken and Tyre's rationale is sound — content authors (Mellanie, Paula) need to write and read these files, and serde_yaml is mature enough for our needs.

What Changes in the Loader

Almost nothing structurally. The serde derive macros work identically across formats — the Rust structs stay the same, only the deserializer call changes:

// Round 1 (RON):
// let profile: NpcProfile = ron::from_str(&content)?;

// Round 2 (YAML):
let profile: NpcProfile = serde_yaml::from_str(&content)?;

The 6-phase load sequence from Round 1 is format-agnostic. All phase ordering, validation rules, and resource creation remain identical. Specific adaptations:

Item Round 1 (RON) Round 2 (YAML) Impact
Manifest content/manifest.ron content/content.yaml Filename only
NPC profiles .ron files .yaml files Extension only
Dialogue/monologue .ron files .yaml files Extension only
Multi-line text Rust string escaping YAML native | / > blocks Better for authors — no escaping
Validation Serde-only Serde + JSON Schema (Tyre's dual validation) Strictly more validation
Dependency ron crate serde_yaml crate Swap one dependency

The one genuine improvement: YAML's native multi-line text support (| for literal blocks) means dialogue and monologue text will be cleaner in the content files. No escaped newlines, no awkward string concatenation. Content authors benefit directly.

Cargo.toml Change

# Add:
serde_yaml = "0.9"
# The ron crate stays for potential future use (RON converter per lead's decision)

RON Converter

Per the lead's decision, we build a RON converter. My recommendation:

  • Location: tooling/content-converter/ — a small Rust binary, not part of the server
  • Direction: YAML → RON only (content files are authored in YAML; if we ever want RON for hot-path loading, the converter produces it)
  • When it runs: Build-time, optional. Not load-time — the server loads YAML directly. The converter is insurance for a hypothetical future where YAML parse speed matters (it won't for v0.1)
  • Priority: Low. Build it when someone asks for it, not proactively

2. Multi-Action Interaction Model

The Architecture: N Actions, 1 Client Selection

The server computes ALL available actions for each nearby entity. The InteractionOptions struct from Round 1 already supports this. The v0.1 client selects the top-ranked action to display. The v0.2 client shows the full list.

/// Available interactions for a nearby entity.
/// Server computes all; client displays based on version.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityInteractions {
    /// StableId of the target entity
    pub entity_id: u64,
    /// Display name (knowledge-filtered — "Dock Worker" or "Kael Davan")
    pub display_name: String,
    /// All available actions, ordered by priority (highest first).
    /// v0.1 client shows actions[0] only.
    /// v0.2+ client shows all (radial menu, list, etc.)
    pub actions: Vec<AvailableAction>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableAction {
    /// Action identifier used in PlayerAction::InteractWith
    pub action_id: String,
    /// What the action is
    pub kind: ActionKind,
    /// Display label for the client ("Talk", "Examine", "Ask about the schedule")
    pub label: String,
    /// Priority for v0.1 single-action selection (higher = shown first)
    pub priority: u8,
    /// Can this action be performed right now?
    pub enabled: bool,
    /// Why not? (shown as tooltip in v0.2+, ignored in v0.1)
    pub disabled_reason: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionKind {
    /// Study the entity without initiating contact.
    /// Updates knowledge graph with behavioral observations.
    Examine,
    /// Initiate dialogue. Opens conversation state.
    Talk,
    /// Look at an environmental object. Returns description + knowledge grants.
    ExamineObject,
    /// Listen to a nearby NPC-NPC conversation. Passive — no social cost.
    Overhear,
}

Priority Ranking System

The server determines action priority per context. This is where the "single context-sensitive action" behavior lives:

/// Determine default action priority for a given entity and context.
/// Returns actions sorted by priority (highest first).
fn rank_actions(
    target_kind: EntityKind,
    observer_knowledge: &KnowledgeGraph,
    target_stable_id: &StableId,
    target_in_conversation: bool,
    target_activity: &str,
) -> Vec<AvailableAction> {
    let mut actions = Vec::new();

    match target_kind {
        EntityKind::Npc => {
            // Examine is always available for NPCs in LOS
            let examine_priority = if observer_knowledge
                .entity_knowledge(target_stable_id)
                .map(|ek| ek.known_attributes.contains_key("contradiction_flagged"))
                .unwrap_or(false)
            {
                // Post-contradiction: Examine becomes high priority
                // "Watch them more carefully"
                90
            } else {
                // Default: Examine is secondary to Talk
                40
            };

            actions.push(AvailableAction {
                action_id: format!("examine_{}", target_stable_id.0),
                kind: ActionKind::Examine,
                label: "Observe".to_string(),
                priority: examine_priority,
                enabled: true,
                disabled_reason: None,
            });

            // Talk: available if NPC is not sleeping, not in combat, not already
            // in conversation with someone else
            let talk_enabled = !target_in_conversation
                && target_activity != "sleeping";

            actions.push(AvailableAction {
                action_id: format!("talk_{}", target_stable_id.0),
                kind: ActionKind::Talk,
                label: "Talk".to_string(),
                priority: if talk_enabled { 50 } else { 10 },
                enabled: talk_enabled,
                disabled_reason: if !talk_enabled {
                    Some("Busy".to_string())
                } else {
                    None
                },
            });

            // Overhear: available if NPC is in conversation with another NPC
            if target_in_conversation {
                actions.push(AvailableAction {
                    action_id: format!("overhear_{}", target_stable_id.0),
                    kind: ActionKind::Overhear,
                    label: "Listen".to_string(),
                    priority: 60,
                    enabled: true,
                    disabled_reason: None,
                });
            }
        }
        EntityKind::Object => {
            actions.push(AvailableAction {
                action_id: format!("examine_obj_{}", target_stable_id.0),
                kind: ActionKind::ExamineObject,
                label: "Examine".to_string(),
                priority: 50,
                enabled: true,
                disabled_reason: None,
            });
        }
        _ => {}
    }

    // Sort by priority descending
    actions.sort_by(|a, b| b.priority.cmp(&a.priority));
    actions
}

How v0.1 and v0.2 Differ — Client Side Only

Behavior v0.1 v0.2+
Actions computed ALL (server computes full list) ALL (same)
Actions sent to client ALL (via EntityInteractions.actions) ALL (same)
Actions displayed actions[0] only — single prompt All enabled actions — menu/radial
Player input Interact → server applies actions[0] InteractWith(action_id) → server applies specific action
Architecture change needed for v0.2 None on server. Client reads more of the existing data. Client UI change only

The key guarantee: zero server-side changes for v0.2 multi-verb. The server already sends everything. The client just reads more of what's already there.

PlayerAction Extension

pub enum PlayerAction {
    // ... existing movement variants ...

    /// v0.1: interact with nearest entity, server picks top action.
    Interact,

    /// v0.2+: interact with specific entity using specific action.
    /// Falls back to top action if action_id is None.
    InteractWith {
        target_id: u64,        // StableId of target
        action_id: Option<String>,  // specific action, or None for default
    },

    /// Select a dialogue topic during active conversation.
    DialogueSelect { topic: String },

    /// End the active conversation.
    DialogueEnd,
}

For v0.1, the client sends Interact. The server resolves to the nearest entity and applies actions[0]. When v0.2 introduces multi-verb UI, the client sends InteractWith { target_id, action_id }. The server handles both — Interact is syntactic sugar for "find nearest, pick top action."

Stig's Question: IPC Message Format

Stig asked what the state update looks like. The answer: full text in the message, not IDs for client-side lookup. This keeps the client thin and prevents information leakage. The ObserverSnapshot already carries everything the client needs. Interaction data rides the same channel:

/// ObserverSnapshot v3 — adds interaction and dialogue data.
pub struct ObserverSnapshot {
    pub version: u8,  // 3
    pub tick: u64,
    pub game_time: GameTime,
    pub player_facing: FacingDirection,
    pub entities: Vec<VisibleEntity>,
    pub visible_tiles: Vec<VisibleTile>,

    // v3 additions:

    /// Entities within interaction range with available actions.
    /// Empty if no interactable entities are nearby.
    pub nearby_interactions: Vec<EntityInteractions>,

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

    /// Monologue lines triggered this tick. Usually 0 or 1.
    pub monologue: Vec<MonologueDisplay>,

    /// Overheard conversation fragments. Passive, proximity-based.
    pub overheard: Vec<OverheardDisplay>,

    /// Current simulation speed (1.0 = normal, 0.5 = overlay, 0.0 = paused).
    /// Client uses this to adjust animation/interpolation rates.
    pub sim_speed: f32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveDialogue {
    pub npc_entity_id: u64,
    pub npc_name: String,
    pub npc_relationship_color: RelationshipState,
    /// Current NPC line. None before first line / between lines.
    pub current_line: Option<DialogueLineDisplay>,
    /// Available topics the player can raise. Empty if conversation is NPC-led.
    pub available_topics: Vec<TopicOption>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DialogueLineDisplay {
    pub line_id: String,
    pub text: String,
    pub mood: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicOption {
    pub topic_id: String,
    pub label: String,
    pub enabled: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonologueDisplay {
    pub line_id: String,
    pub text: String,
    pub trigger: String,
    /// true = use urgent chime (observe_anomaly, discover_evidence)
    pub urgent: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverheardDisplay {
    pub speaker_id: u64,
    pub speaker_name: String,
    pub fragment: String,
}

Everything the client needs to render is in the snapshot. The client never queries content files. The server is the single source of truth.


3. Pause System — Tick Rate Modulation

The Model: SimulationSpeed Resource

Currently SimulationTime has a boolean paused field. That's insufficient for 50% speed. I'll replace it with a speed multiplier model:

/// Simulation speed states.
/// Determines how many ticks advance per real-time frame.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum SimSpeed {
    /// Normal speed: 1 tick per frame at target TPS (10 tps per D-031).
    Normal,
    /// Reduced speed: tick every other frame (50% speed).
    /// Used when UI overlays are active (knowledge panel, dialogue).
    Overlay,
    /// Full pause: no ticks advance.
    /// Spacebar toggle. Always available.
    Paused,
}

impl SimSpeed {
    /// Returns the tick advance rate.
    /// Normal = advance every frame.
    /// Overlay = advance every 2nd frame.
    /// Paused = never advance.
    pub fn should_tick(&self, frame_counter: u64) -> bool {
        match self {
            SimSpeed::Normal => true,
            SimSpeed::Overlay => frame_counter % 2 == 0,
            SimSpeed::Paused => false,
        }
    }

    /// Serialized speed for client animation adjustment.
    pub fn as_f32(&self) -> f32 {
        match self {
            SimSpeed::Normal => 1.0,
            SimSpeed::Overlay => 0.5,
            SimSpeed::Paused => 0.0,
        }
    }
}

/// Resource tracking simulation speed and frame count.
#[derive(Resource, Debug, Clone)]
pub struct SimulationSpeed {
    /// Current speed state.
    pub speed: SimSpeed,
    /// Frame counter for Overlay tick skipping.
    pub frame_counter: u64,
    /// Stack of speed modifiers. Highest priority wins.
    /// When all modifiers are removed, returns to Normal.
    speed_stack: Vec<SpeedModifier>,
}

#[derive(Debug, Clone)]
struct SpeedModifier {
    source: SpeedSource,
    speed: SimSpeed,
    priority: u8,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpeedSource {
    /// Spacebar toggle — highest priority, always wins.
    PlayerPause,
    /// Knowledge panel open.
    KnowledgePanel,
    /// Active dialogue with NPC.
    Dialogue,
}

State Machine

                    ┌──────────┐
            ┌──────→│  Normal  │◄──────┐
            │       │ (1.0x)   │       │
            │       └────┬─────┘       │
            │            │             │
     close overlay   open overlay   spacebar
            │            │             │
            │       ┌────▼─────┐       │
            ├──────→│ Overlay  │──────→│
            │       │ (0.5x)   │       │
            │       └────┬─────┘       │
            │            │             │
            │        spacebar          │
            │            │             │
            │       ┌────▼─────┐       │
            └───────│  Paused  │───────┘
                    │ (0.0x)   │
                    └──────────┘

Rules:

  1. Spacebar always toggles full pause. From Normal → Paused. From Overlay → Paused. From Paused → returns to whatever state was active before pause (Normal or Overlay).
  2. Opening knowledge panel or dialogue → Overlay (50%). Multiple overlays don't stack — it's 50% whether you have one or three open.
  3. Closing all overlays → Normal. But only if not explicitly paused by spacebar.
  4. Spacebar overrides everything. It's the master switch.

Implementation: Modified advance_tick

/// System: advance tick based on current simulation speed.
/// Replaces the current boolean-paused advance_tick.
pub fn advance_tick(
    mut time: ResMut<SimulationTime>,
    mut speed: ResMut<SimulationSpeed>,
) {
    speed.frame_counter += 1;

    if speed.speed.should_tick(speed.frame_counter) {
        time.tick += 1;
    }
}

/// System: process pause/speed PlayerActions.
pub fn process_speed_input(
    mut speed: ResMut<SimulationSpeed>,
    input_events: Res<InputQueue>,
    // ... other input handling
) {
    // Spacebar pause toggle
    // When paused by spacebar, restore previous speed on unpause
    // Overlay open/close events modify the speed stack
}

What the Client Needs to Know

The sim_speed: f32 field in ObserverSnapshot v3 tells the client:

  • 1.0 → normal animation/interpolation
  • 0.5 → halve animation rates, entities move at half speed visually
  • 0.0 → freeze all animation, UI stays responsive

The client doesn't manage pause state. It reads the speed from the snapshot and adjusts rendering accordingly. The server is authoritative.

Dialogue-Specific Pause Behavior

When dialogue is active (player is talking to an NPC), simulation runs at Overlay speed (50%). This means:

  • NPCs continue their routines at half speed — the world doesn't freeze when you talk
  • Other NPCs can walk past during your conversation — you might miss something
  • But it's slow enough that the player doesn't feel punished for engaging in dialogue

If the player hits spacebar during dialogue, full pause. They can read at their own pace. Spacebar again resumes to Overlay (because dialogue is still active).


4. FriendArc Phase Transitions — Triggers and Content Dependencies

Phase Transition State Machine

  WARMTH ──────→ TRUST ──────→ DOUBT ──────→ CONFLICT
    │               │              │              │
    │ auto (tick)   │ knowledge    │ observation   │ knowledge +
    │               │ threshold    │ + monologue   │ dialogue
    │               │              │              │
    ▼               ▼              ▼              ▼
  "We're good"   "I trust you"  "Something's   "I know what
                                  wrong"         you did"

Phase-by-Phase: Triggers, Content, and What the Server Needs

Phase 1: WARMTH → TRUST

Trigger: Interaction count threshold + time elapsed.

/// Transition from Warmth to Trust.
/// This is the "getting to know you" phase — automatic progression
/// as the player interacts with the FRIEND over time.
fn check_warmth_to_trust(
    friend_arc: &FriendArc,
    interaction_log: &InteractionLog,
    time: &SimulationTime,
) -> bool {
    let interactions_with_friend = interaction_log
        .count_interactions(friend_arc.bonded_character, friend_arc.friend_entity);
    let time_since_first = time.tick.saturating_sub(
        interaction_log.first_interaction_tick(
            friend_arc.bonded_character, friend_arc.friend_entity
        ).unwrap_or(time.tick)
    );

    // Require both: enough interactions AND enough time.
    // Prevents rushing through the arc by spam-talking.
    interactions_with_friend >= 3 && time_since_first >= 600 // 1 game-hour
}

Content required:

  • 5-8 dialogue lines for Warmth phase (casual, friendly, establishing rapport)
  • 3-5 monologue lines for Warmth (positive: "Kael's reliable. Good to have someone you can count on.")
  • Trust transition is silent — no dramatic moment. The player just notices the FRIEND is warmer

Server state change: FriendArc.phase = Trust. No knowledge graph change. No visual change. The NPC's dialogue pool shifts to Trust-phase lines (gated by the situation: [friend_trust] tag).

Phase 2: TRUST → DOUBT

Trigger: Knowledge threshold — the player character learns specific facts that create tension.

/// Transition from Trust to Doubt.
/// Requires the bonded character to accumulate suspicious knowledge.
/// NOT a single event — it's a gradual shift.
fn check_trust_to_doubt(
    friend_arc: &FriendArc,
    observer_kg: &KnowledgeGraph,
) -> bool {
    // The character must know at least ONE of the doubt-triggering facts
    // at KnowsOf or higher confidence.
    let doubt_facts = &friend_arc.doubt_trigger_facts;

    doubt_facts.iter().any(|fact_id| {
        observer_kg.fact_at_least(fact_id, KnowledgeConfidence::KnowsOf)
    })
}

Content required for Kael (smuggler's FRIEND):

  • Doubt-triggering facts (defined in NPC profile):
    • "kael.schedule_inconsistency" — Kael's routine doesn't fully match the posted schedule
    • "ring.internal_pressure" — the smuggler learns the ring is under strain
  • 5-8 dialogue lines for Trust phase (deeper, more personal, sharing more)
  • 3-5 monologue lines for Doubt phase (questioning: "Kael left early again. Third time this rotation. Where does he go?")
  • Tell acceleration: Kael's tells become more frequent in Doubt phase. The tell_stage counter advances, making tells more visible

Content required for Sera (detective's FRIEND):

  • Doubt-triggering facts:
    • "sera.avoidance_pattern" — Sera consistently avoids Torek Lintar
    • "investigation.evidence_gap" — evidence exists that should have been reported but wasn't
  • Same line counts as Kael, detective-perspective

Server state change: FriendArc.phase = Doubt. Tell stage advances (tell_stage += 1). Monologue pool shifts. No visual change yet — the entity color stays green (Friendly). The player should feel uneasy but not alarmed.

Phase 3: DOUBT → CONFLICT

Trigger: Direct observation of the contradiction. This is the critical moment — wow moment #3.

/// Transition from Doubt to Conflict.
/// Requires DIRECT OBSERVATION of the contradiction.
/// This cannot be told to the player — they must SEE it.
fn check_doubt_to_conflict(
    friend_arc: &FriendArc,
    observer_kg: &KnowledgeGraph,
) -> bool {
    // ALL contradiction facts must be known at KnowsDetails or higher.
    // At least one must have source = DirectObservation.
    let all_known = friend_arc.contradiction_facts.iter().all(|fact_id| {
        observer_kg.fact_at_least(fact_id, KnowledgeConfidence::KnowsDetails)
    });

    // The discovery flag is set by the spatial staging system
    // when the player observes the FRIEND in the wrong place.
    all_known && friend_arc.contradiction_discovered
}

How contradiction_discovered gets set — spatial staging:

This is the hardest part. The server must arrange for the FRIEND to be in a specific place at a specific time, and the player must be able to observe it. The simulation can't script this — it must create the opportunity and let the player find it.

/// System: check if the bonded character observes the FRIEND
/// in a contradiction location during a contradiction window.
fn check_contradiction_observation(
    friend_query: Query<(&FriendArc, &NpcRoutine, &TilePosition), With<Npc>>,
    observer_query: Query<(&KnowledgeGraph, &TilePosition), With<PlayerCharacter>>,
    location_map: Res<LocationMap>,
    time: Res<SimulationTime>,
    mut event_queue: ResMut<KnowledgeEventQueue>,
) {
    for (arc, routine, npc_pos) in friend_query.iter() {
        if arc.phase != FriendPhase::Doubt {
            continue;
        }

        // Is the FRIEND currently at their contradiction location?
        let at_contradiction = arc.contradiction_location.as_ref()
            .map(|loc| location_map.contains_position(loc, npc_pos))
            .unwrap_or(false);

        // Is it the right time window?
        let in_window = arc.contradiction_time_window.as_ref()
            .map(|w| w.active_at(&time))
            .unwrap_or(true); // no time window = always active

        if !at_contradiction || !in_window {
            continue;
        }

        // Is the bonded character observing?
        // (They must have LOS to the FRIEND's current position)
        // The perception system handles LOS. We check if the bonded
        // character has Direct confidence on the FRIEND.
        for (obs_kg, _obs_pos) in observer_query.iter() {
            if let Some(entry) = obs_kg.entity_knowledge(&arc.friend_stable_id) {
                if entry.confidence == KnowledgeConfidence::Direct {
                    // The player character is looking at their FRIEND
                    // in the wrong place at the wrong time.
                    // This is wow moment #3.

                    // Emit knowledge event for contradiction facts
                    // Emit monologue trigger (observe_npc with deviation)
                    // Set contradiction_discovered = true
                }
            }
        }
    }
}

Content required for the contradiction moment:

  • Kael: Observed in Corridor B-7 (maintenance corridors) during shift transition, meeting unknown contact. The smuggler knows Kael has no reason to be there.
    • Monologue line (urgent chime): "Kael? In Corridor B-7? During shift transition? He has no reason to be there. None that I know of." (terminal_m_s_012 from Tyre's example)
    • Knowledge grant: "kael.secret_meetings": KnowsDetails
    • Knowledge grant: "kael.restricted_corridor_visits": KnowsDetails
  • Sera: Observed avoiding Torek Lintar (turning away when he enters the bar). The detective recognizes the pattern.
    • Monologue line (urgent chime): detective-equivalent — recognizing deliberate avoidance
    • Knowledge grant: "sera.avoidance_pattern": KnowsDetails

Server state change: FriendArc.phase = Conflict. FriendArc.contradiction_discovered = true. RelationshipState shifts: Friendly → PersonOfInterest. Entity color shifts green → amber (D-033, 0.5s fade). Tell stage maxes out. Dialogue pool shifts to Conflict-phase lines.

This is the single most important moment in the vertical slice. State consistency requires:

  1. Knowledge graph updated with contradiction facts
  2. FriendArc phase advanced
  3. RelationshipState changed
  4. Monologue triggered (urgent)
  5. Entity color change visible in next ObserverSnapshot

All five must happen atomically within the same tick. I need to verify the tick order handles this correctly — knowledge event processing must complete before snapshot generation.

Phase 4: CONFLICT (Terminal)

Trigger: None — this is the final phase. The player is in Conflict with their FRIEND.

Content required:

  • 8-12 Conflict-phase dialogue lines (confrontation options, defensive NPC responses, the NPC's sympathetic motivation revealed)
  • 5-8 Conflict-phase monologue lines (the character processing betrayal, re-evaluating past interactions)
  • No clean resolution — D-034 mandates this. The conversation can happen, understanding can emerge, but the trust is broken

Server state: FriendArc.phase = Conflict. RelationshipState stays PersonOfInterest (or shifts to Hostile if the confrontation goes badly). The arc is complete.

Revised FriendArc Component

#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct FriendArc {
    /// Which character this FRIEND is bonded to (StableId).
    pub bonded_character: StableId,
    /// StableId of this FRIEND NPC (for knowledge graph lookups).
    pub friend_stable_id: StableId,
    /// Current phase.
    pub phase: FriendPhase,

    // --- Transition triggers ---

    /// Facts that trigger Doubt phase (any one sufficient).
    pub doubt_trigger_facts: Vec<FactId>,
    /// Facts that constitute the full contradiction (all required for Conflict).
    pub contradiction_facts: Vec<FactId>,
    /// Location where the contradiction occurs.
    pub contradiction_location: Option<String>,  // canonical location ID
    /// Time window for the contradiction (day phase + optional minute range).
    pub contradiction_time_window: Option<TimeWindow>,

    // --- State tracking ---

    /// Tell visibility stage (0 = minimal, advances as phase progresses).
    pub tell_stage: u8,
    /// Has the bonded character directly observed the contradiction?
    pub contradiction_discovered: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeWindow {
    pub phase: DayPhase,
    pub start_minute: Option<u64>,  // within the phase
    pub end_minute: Option<u64>,
}

impl TimeWindow {
    pub fn active_at(&self, time: &SimulationTime) -> bool {
        if time.day_phase() != self.phase {
            return false;
        }
        let tod = time.time_of_day_minutes();
        let phase_start = match self.phase {
            DayPhase::Morning => 0,
            DayPhase::Afternoon => 360,
            DayPhase::Evening => 720,
            DayPhase::Night => 1080,
        };
        let start = phase_start + self.start_minute.unwrap_or(0);
        let end = phase_start + self.end_minute.unwrap_or(360);
        tod >= start && tod < end
    }
}

Content Summary — What I Need from Mellanie and Paula

FRIEND NPC Phase Lines Needed Type Blocker?
Kael Warmth 5-8 dialogue (casual, friendly) No — generic enough to synthesize
Kael Warmth 3-5 monologue (positive, routine) No
Kael Trust 5-8 dialogue (personal, deeper) Yes — needs character voice
Kael Trust 3-5 monologue (warming, comfortable) Yes
Kael Doubt 5-8 dialogue (evasive, stressed) Yes
Kael Doubt 3-5 monologue (questioning, uneasy) Yes
Kael Conflict 8-12 dialogue (confrontation, revelation) Yes — most critical
Kael Conflict 5-8 monologue (processing, betrayal) Yes
Kael Contradiction 1 monologue (urgent, wow moment #3) CRITICAL
Sera (same counts) ~35-46 (same breakdown) Same blockers

Total per FRIEND NPC: ~35-46 authored lines minimum. Total for both: ~70-92 lines.

This aligns with D-034's estimate of 70-100 per FRIEND. The breakdown by phase is what I need to test each transition independently.

Interaction Log Component

I need a lightweight interaction tracking component to support the Warmth → Trust transition:

/// Tracks interaction history between entities.
/// Used for FriendArc Warmth→Trust transition and future NOBODY promotion.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct InteractionLog {
    /// (target_stable_id, tick) pairs.
    /// BTreeMap for deterministic iteration.
    pub interactions: BTreeMap<StableId, Vec<u64>>,
}

impl InteractionLog {
    pub fn new() -> Self {
        Self { interactions: BTreeMap::new() }
    }

    pub fn record(&mut self, target: StableId, tick: u64) {
        self.interactions.entry(target).or_default().push(tick);
    }

    pub fn count(&self, target: &StableId) -> usize {
        self.interactions.get(target).map(|v| v.len()).unwrap_or(0)
    }

    pub fn first_tick(&self, target: &StableId) -> Option<u64> {
        self.interactions.get(target).and_then(|v| v.first().copied())
    }
}

This also becomes the foundation for NOBODY tier promotion in v0.2 — the same interaction counter that drives FriendArc Warmth→Trust will drive NOBODY→NOTICED. Building it now means zero rework later.


Summary of Round 2 Deliverables

Decision Resolution
YAML format Accepted. serde_yaml replaces ron for content loading. RON converter in tooling/ as low-priority insurance.
Multi-action model Server computes ALL available actions per entity, ranked by priority. v0.1 client shows actions[0]. v0.2 client shows all. Zero server changes needed for v0.2.
Pause system SimSpeed enum: Normal (1.0x), Overlay (0.5x), Paused (0.0x). Spacebar always toggles full pause. UI overlays trigger Overlay speed. Speed stack with priority resolution.
FriendArc transitions 4-phase state machine with explicit triggers: Warmth (interaction count + time) → Trust (knowledge threshold) → Doubt (specific facts) → Conflict (direct observation of contradiction). Contradiction detection requires spatial staging system.

Dependencies I'm Tracking

  1. ObserverSnapshot v3 — Tyre needs to sign off on the protocol extension. I've proposed the full struct. Stig needs the sim_speed field and nearby_interactions / active_dialogue / monologue / overheard fields.
  2. FRIEND content packs — Mellanie needs to produce phase-tagged dialogue and monologue lines per the breakdown above. These unblock integration testing of the FriendArc state machine.
  3. Contradiction spatial data — Paula needs to specify the exact contradiction setup for Kael and Sera in the NPC profiles: which location, which time window, what the contradiction looks like when observed.
  4. FactId catalog — Still the #1 blocker. I need the machine-readable fact vocabulary before I can build prerequisite evaluation. Gestalt and Paula produce this.