Files
settled-reach/docs/workshops/v01-content-scoping/round2-tyre.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

40 KiB
Raw Blame History

Round 2 — Tyre (Technical Architect)

v0.1 Content Scoping Workshop

Four tasks from the lead. Let me work through them.


Task 1: Finalized Content Directory YAML Structure + Schema Snippets

YAML confirmed. Here's the finalized structure with actual JSON Schema definitions for the core content types. These schemas are the contract between copy (authoring) and server (loading).

Final Directory Tree

content/
  content.yaml                        # Manifest: district list, content version, load order
  schema/                             # JSON Schema definitions for YAML validation
    npc-profile.schema.json
    location.schema.json
    fact-catalog.schema.json
    district.schema.json
    dialogue-pool.schema.json
    monologue-pool.schema.json
    routine.schema.json
    triangle.schema.json
  global/                             # District-independent vocabulary
    facts/
      contraband.yaml
      location.yaml
      investigation.yaml
      world.yaml
      relationship.yaml
      progress.yaml
    factions/
      lattice-commission.yaml
      syndics.yaml
      the-ring.yaml
      concord-assembly.yaml
      guardians-of-autonomy.yaml
      veil-institute.yaml
      the-unbound.yaml
    enums/
      situations.yaml                 # 13 situation values (D-035)
      topics.yaml                     # 9 topic values
      moods.yaml                      # 8 mood values
      access-tiers.yaml               # public, insider, authority, peer, hostile
      trust-tiers.yaml                # surface, real, secret
      triggers.yaml                   # 9 monologue trigger types
      patterns.yaml                   # 9 thematic patterns (System A)
      motivations.yaml                # 6 functional motivations (System B)
    entity-schema/
      attributes.yaml                 # 14 canonical EntityKnowledge keys
  districts/
    sova-transit/
      district.yaml
      npcs/
        kael-davan.yaml
        sera-venn.yaml
        voss.yaml
        lera-sessik.yaml
        torek-lintar.yaml
        devra.yaml
        maret-korr.yaml
        resha.yaml
        naia-tamm.yaml
        renn.yaml
        pell.yaml
        harek.yaml
        drin.yaml
        sess.yaml
        olin.yaml
        sabel.yaml
        tav.yaml
      locations/
        the-terminal.yaml
        the-last-shift.yaml
        maintenance-corridors.yaml
      triangles/
        hub-power.yaml
        worried-knowledge.yaml
        bar-tensions.yaml
        worried-partner.yaml
        informant-question.yaml
      dialogue/
        the-terminal/
          dock-worker.yaml
          shift-supervisor.yaml
          scheduler.yaml
          new-hire.yaml
          courier.yaml
        the-last-shift/
          bar-owner.yaml
          bartender.yaml
          bar-regular.yaml
        maintenance-corridors/
          ring-operative.yaml
      monologue/
        smuggler/
          the-terminal.yaml
          the-last-shift.yaml
          maintenance-corridors.yaml
          general.yaml
        detective/
          the-terminal.yaml
          the-last-shift.yaml
          maintenance-corridors.yaml
          general.yaml
      routines/
        schedules.yaml

Schema: Content Manifest

# content/content.yaml
version: "0.1.0"
districts:
  - id: "sova-transit"
    path: "districts/sova-transit"
    enabled: true

Schema: NPC Profile (npc-profile.schema.json)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "NpcProfile",
  "description": "NPC content profile for The Settled Reach. Maps to server NpcBundle.",
  "type": "object",
  "required": ["canonical_id", "display_name", "short_name", "tier", "pattern", "motivation", "district", "axes", "initial_attributes"],
  "properties": {
    "canonical_id": {
      "type": "string",
      "pattern": "^npc:[a-z0-9-]+$",
      "description": "Stable content address. Format: npc:{slug}"
    },
    "display_name": { "type": "string", "minLength": 1 },
    "short_name": { "type": "string", "minLength": 1 },
    "tier": { "enum": [1, 2, 3] },
    "pattern": {
      "enum": ["FRIEND", "MIRROR", "ANCHOR", "GHOST", "CATALYST", "THRESHOLD", "REMNANT", "SYSTEM", "NOBODY"]
    },
    "motivation": {
      "enum": ["HANDLER", "WITNESS", "TURNCOAT", "CIVILIAN", "OPERATOR", "SKEPTIC"]
    },
    "district": { "type": "string" },
    "axes": {
      "type": "object",
      "required": ["want", "secret", "relationships", "tolerance", "routine", "information", "contentment"],
      "properties": {
        "want": { "type": "string" },
        "secret": {
          "type": "object",
          "required": ["surface"],
          "properties": {
            "surface": { "type": "string" },
            "deep": { "type": "string" }
          }
        },
        "relationships": {
          "type": "array",
          "items": {
            "type": "object",
            "required": ["target", "kind", "trust"],
            "properties": {
              "target": {
                "type": "string",
                "pattern": "^npc:[a-z0-9-]+$",
                "description": "canonical_id of relationship target"
              },
              "kind": { "type": "string" },
              "trust": { "type": "number", "minimum": 0.0, "maximum": 1.0 }
            }
          }
        },
        "tolerance": {
          "type": "object",
          "required": ["current_stress", "threshold"],
          "properties": {
            "current_stress": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
            "threshold": { "type": "number", "minimum": 0.0, "maximum": 1.0 }
          }
        },
        "routine": {
          "type": "object",
          "required": ["description"],
          "properties": {
            "description": { "type": "string" }
          }
        },
        "information": {
          "type": "object",
          "required": ["known_facts"],
          "properties": {
            "known_facts": {
              "type": "array",
              "items": {
                "type": "string",
                "pattern": "^[a-z_]+\\.[a-z_]+:(Suspects|KnowsOf|KnowsDetails)$",
                "description": "Format: fact_id:ConfidenceLevel"
              }
            }
          }
        },
        "contentment": { "type": "number", "minimum": 0.0, "maximum": 1.0 }
      }
    },
    "personality": {
      "type": "object",
      "properties": {
        "traits": { "type": "array", "items": { "type": "string" } }
      }
    },
    "tells": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["trigger", "behavior"],
        "properties": {
          "trigger": { "type": "string" },
          "behavior": { "type": "string" }
        }
      }
    },
    "skills": {
      "type": "object",
      "properties": {
        "set": { "type": "array", "items": { "type": "string" } },
        "combat_trained": { "type": "boolean", "default": false }
      }
    },
    "initial_attributes": {
      "type": "object",
      "required": ["role", "faction", "species"],
      "properties": {
        "role": { "type": "string" },
        "faction": { "type": "string" },
        "species": { "type": "string", "default": "human" },
        "routine_pattern": { "type": "string" }
      },
      "description": "What a brand-new observer would learn about this NPC on first sight. Maps to EntityKnowledge.known_attributes."
    },
    "access_tiers": {
      "type": "object",
      "properties": {
        "default": { "enum": ["public", "insider", "authority", "peer", "hostile"] },
        "overrides": {
          "type": "object",
          "additionalProperties": {
            "enum": ["public", "insider", "authority", "peer", "hostile"]
          },
          "description": "canonical_id → access tier overrides"
        }
      }
    },
    "trust_levels": {
      "type": "object",
      "additionalProperties": {
        "enum": ["surface", "real", "secret"]
      },
      "description": "canonical_id → trust tier"
    },
    "friend_arc": {
      "type": "object",
      "properties": {
        "character": { "enum": ["smuggler", "detective"] },
        "phases": {
          "type": "array",
          "items": {
            "type": "object",
            "required": ["name"],
            "properties": {
              "name": { "enum": ["warmth", "trust", "doubt", "conflict"] },
              "triggers": { "type": "array" }
            }
          }
        },
        "contradiction": {
          "type": "object",
          "properties": {
            "type": { "type": "string" },
            "location": { "type": "string" },
            "expected_location": { "type": "string" },
            "time_window": { "type": "string" }
          }
        }
      },
      "description": "Only present for FRIEND pattern NPCs (Tier 1)"
    },
    "triangle_membership": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["triangle_id", "role"],
        "properties": {
          "triangle_id": { "type": "string" },
          "role": { "type": "string" }
        }
      }
    }
  }
}

Schema: Dialogue Pool (dialogue-pool.schema.json)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "DialoguePool",
  "description": "Tagged dialogue line pool per D-028, D-035.",
  "type": "object",
  "required": ["role", "location", "lines"],
  "properties": {
    "role": { "type": "string", "description": "Template-defined role (not NPC name)" },
    "location": {
      "type": "string",
      "pattern": "^loc:[a-z0-9-]+:[a-z0-9-]+$"
    },
    "lines": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "text", "access", "trust", "situation"],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-z_]+_d_[0-9]{3}$",
            "description": "{location}_{d}_{###} per D-035"
          },
          "text": { "type": "string", "maxLength": 300 },
          "access": {
            "type": "array",
            "items": { "enum": ["public", "insider", "authority", "peer", "hostile"] },
            "minItems": 1
          },
          "trust": { "enum": ["surface", "real", "secret"] },
          "situation": {
            "type": "array",
            "items": {
              "enum": [
                "arrival", "shift_start", "shift_end", "shift_transition",
                "bar_evening", "night_shift", "investigation", "confrontation",
                "social", "alone", "emergency", "routine", "observation"
              ]
            },
            "minItems": 1
          },
          "topic": {
            "type": "array",
            "items": {
              "enum": [
                "colleague", "routine", "cargo", "money", "trust",
                "danger", "institution", "personal", "investigation"
              ]
            }
          },
          "mood": {
            "type": "array",
            "items": {
              "enum": [
                "fond", "comfortable", "worried", "suspicious",
                "analytical", "conflicted", "concerned", "relieved"
              ]
            }
          },
          "tags": {
            "type": "array",
            "items": { "type": "string" }
          },
          "knowledge_grants": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": { "enum": ["learn_fact", "learn_attribute", "update_relationship"] },
                "fact_id": { "type": "string" },
                "confidence": { "enum": ["Suspects", "KnowsOf", "KnowsDetails"] },
                "target": { "type": "string" },
                "key": { "type": "string" },
                "value": { "type": "string" }
              }
            },
            "description": "Knowledge updates that occur when this line is spoken to the player"
          }
        }
      }
    }
  }
}

Schema: Monologue Pool (monologue-pool.schema.json)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "MonologuePool",
  "description": "Tagged monologue line pool per D-032, D-035. Hard-partitioned by character.",
  "type": "object",
  "required": ["character", "location", "lines"],
  "properties": {
    "character": { "enum": ["smuggler", "detective"] },
    "location": { "type": "string" },
    "lines": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "text", "trigger"],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-z_]+_m_[sd]_[0-9]{3}$",
            "description": "{location}_m_{s|d}_{###}"
          },
          "text": {
            "type": "string",
            "maxLength": 160,
            "description": "~160 chars max, 2-line display per Stig's constraint"
          },
          "trigger": {
            "enum": [
              "enter_location", "observe_npc", "hear_sound",
              "observe_anomaly", "post_conversation", "discover_evidence",
              "witness_interaction", "time_idle", "return_visit"
            ]
          },
          "prerequisite": {
            "type": ["object", "null"],
            "properties": {
              "facts": {
                "type": "object",
                "additionalProperties": {
                  "enum": ["Suspects", "KnowsOf", "KnowsDetails"]
                },
                "description": "FactId → minimum confidence. ALL must be met (AND logic)."
              },
              "entity": {
                "type": "object",
                "properties": {
                  "target": { "type": "string" },
                  "attribute": { "type": "string" },
                  "condition": { "type": "string" }
                },
                "description": "Entity attribute check. AND with facts if both present."
              },
              "relationship": {
                "type": "object",
                "properties": {
                  "target": { "type": "string" },
                  "min_state": {
                    "enum": ["Unknown", "Known", "Friendly", "PersonOfInterest", "Hostile"]
                  }
                }
              }
            },
            "description": "AND-only prerequisite. All specified conditions must be true. Null = always eligible."
          },
          "topic": {
            "type": "array",
            "items": { "type": "string" }
          },
          "mood": {
            "type": "array",
            "items": { "type": "string" }
          },
          "tags": {
            "type": "array",
            "items": { "type": "string" }
          },
          "priority": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10,
            "default": 5,
            "description": "Higher priority lines are preferred when multiple qualify. 0=lowest, 10=critical (wow moments)."
          },
          "cooldown": {
            "type": "integer",
            "minimum": 0,
            "description": "Minimum ticks before this line can fire again. 0 = no repeat."
          },
          "dual_lens": {
            "type": "object",
            "description": "Authoring-only. Per-character notes for the content team."
          },
          "notes": {
            "type": "string",
            "description": "Authoring-only. Author intent and context."
          }
        }
      }
    }
  }
}

Schema: Fact Catalog Entry

# content/global/facts/contraband.yaml
category: "contraband"
facts:
  - fact_id: "contraband.ring_exists"
    description: "A smuggling operation exists in the district"
    discoverable_by: ["smuggler", "detective"]
    progression:
      Suspects: "Something's going on with the cargo schedules."
      KnowsOf: "There's a smuggling operation running through the logistics hub."
      KnowsDetails: "The ring moves unlicensed lattice components during shift transitions."
    abstract: true      # Never reaches Direct confidence

  - fact_id: "contraband.lattice_components"
    description: "The specific contraband: unlicensed lattice components"
    discoverable_by: ["smuggler", "detective"]
    progression:
      Suspects: "That container's marked as standard, but the mass is wrong."
      KnowsOf: "Unlicensed lattice components. Aftermarket mods."
      KnowsDetails: "Medical-grade neural lattice replacements plus enhanced capability mods."
      Direct: "I'm looking at a crate of lattice components right now."
    abstract: false     # Can reach Direct if player sees contraband

Schema: Schedule

# content/districts/sova-transit/routines/schedules.yaml
schedules:
  - npc: "npc:kael-davan"
    entries:
      - phase: Morning
        location: "loc:sova-transit:the-terminal"
        tile: { x: 42, y: 18, z: 0 }
        activity: "working"
      - phase: Afternoon
        location: "loc:sova-transit:the-terminal"
        tile: { x: 45, y: 20, z: 0 }
        activity: "working"
      - phase: Evening
        location: "loc:sova-transit:the-last-shift"
        tile: { x: 80, y: 55, z: 0 }
        activity: "drinking"
      - phase: Night
        location: "loc:sova-transit:residential"
        tile: { x: 30, y: 70, z: 0 }
        activity: "sleeping"
    deviations:
      - trigger: "friend_arc.doubt"
        phase: Evening
        override_location: "loc:sova-transit:maintenance-corridors"
        override_tile: { x: 15, y: 8, z: -1 }
        override_activity: "meeting_contact"
        description: "Kael meets unauthorized contact during shift transition"

The deviations key is load-bearing for THE FRIEND arc. It tells the routine scheduler: "When the FRIEND arc reaches phase X, change Kael's evening location to Corridor B-7." This is how spatial staging works without scripting — the schedule system handles it as a conditional routine override.

Validation Pipeline (Finalized)

AUTHORING TIME (copy team)
  └── Author writes YAML in content/ directory
      └── IDE/editor with JSON Schema autocomplete (optional)

BUILD TIME (make content-validate)
  ├── JSON Schema validation: check YAML structure against schema files
  ├── Cross-reference validation:
  │   ├── All canonical_id references resolve to existing files
  │   ├── All FactIds in prerequisites reference defined facts
  │   ├── All relationship targets exist
  │   ├── No duplicate canonical_ids across all files
  │   └── Enum values match global/enums/ definitions
  └── FAIL if any error — list all errors, don't stop at first

LOAD TIME (server startup)
  ├── serde_yaml deserialization: YAML → Rust structs
  │   └── Type mismatch = load failure (serde catches schema drift)
  ├── Semantic validation:
  │   ├── StableId assignment (deterministic from sorted canonical_ids)
  │   ├── Relationship wiring (canonical_id → StableId resolution)
  │   └── FriendArc bonding (character reference → StableId)
  └── FAIL FAST on any error — no partial loads

Task 2: Multi-Verb Interaction Architecture

The lead's decision: v0.1 ships single context-sensitive action, but the server models N available actions per entity from day one. Here's how.

Core Design: AvailableActions per Entity

/// What a player can do with a nearby entity.
/// Server computes the full list every tick for entities in interaction range.
/// v0.1 client reads only the primary (priority 0).
/// v0.2 client renders all enabled actions as a verb menu.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableActions {
    /// StableId of the target entity
    pub target_id: u64,
    /// Display name (as known to this observer)
    pub display_name: String,
    /// All available actions, sorted by priority (0 = highest)
    pub actions: Vec<ActionOption>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionOption {
    /// What this action does
    pub kind: ActionKind,
    /// Prompt text shown to the player: "Talk", "Observe", "Examine"
    pub label: String,
    /// Sort priority. 0 = primary action (v0.1 default). Lower = higher priority.
    pub priority: u8,
    /// Is this action currently available?
    pub enabled: bool,
    /// Why it's disabled (shown as tooltip in v0.2+)
    pub disabled_reason: Option<String>,
}

/// Action types the player can perform on entities.
/// Extensible enum — new variants added in future versions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionKind {
    // v0.1 actions
    Talk,
    ExamineNpc,
    ExamineObject,
    // v0.2+ actions (defined now, not surfaced)
    // Trade,
    // Give,
    // Accuse,
    // Follow,
    // UseOn,
}

Priority Resolution (Server-Side)

The server computes priorities based on entity type, observer state, and game context. This is the "context-sensitive" logic:

/// Determine available actions for a target entity given the observer's state.
/// Returns actions sorted by priority (0 = most relevant).
fn compute_actions(
    target: &TargetInfo,
    observer: &ObserverInfo,
    game_state: &GameState,
) -> Vec<ActionOption> {
    let mut actions = Vec::new();

    match target.entity_type {
        EntityType::Npc => {
            // Talk is available if NPC is interactable
            if target.is_interactable && !target.is_hostile {
                actions.push(ActionOption {
                    kind: ActionKind::Talk,
                    label: compute_talk_label(observer, target),
                    priority: 0,  // default primary for NPCs
                    enabled: true,
                    disabled_reason: None,
                });
            }

            // Examine is available if NPC is in LOS
            if target.in_los {
                let examine_priority = if observer.has_investigation_context(target) {
                    0  // Detective with suspicion → Examine becomes primary
                } else {
                    1  // Normal → Examine is secondary
                };
                actions.push(ActionOption {
                    kind: ActionKind::ExamineNpc,
                    label: "Observe".to_string(),
                    priority: examine_priority,
                    enabled: true,
                    disabled_reason: None,
                });
            }

            // If NPC is hostile, Talk is disabled but visible
            if target.is_hostile {
                actions.push(ActionOption {
                    kind: ActionKind::Talk,
                    label: "Talk".to_string(),
                    priority: 1,
                    enabled: false,
                    disabled_reason: Some("Hostile".to_string()),
                });
            }
        }

        EntityType::Object => {
            actions.push(ActionOption {
                kind: ActionKind::ExamineObject,
                label: compute_examine_label(target),
                priority: 0,
                enabled: true,
                disabled_reason: None,
            });
        }
    }

    // Sort by priority, stable ordering for determinism
    actions.sort_by_key(|a| a.priority);
    actions
}

/// Context-sensitive talk label.
/// Changes based on relationship state and investigation progress.
fn compute_talk_label(observer: &ObserverInfo, target: &TargetInfo) -> String {
    match observer.relationship_to(target) {
        RelationshipState::PersonOfInterest => "Question".to_string(),
        RelationshipState::Hostile => "Confront".to_string(),
        _ => "Talk".to_string(),
    }
}

How the Detective's Loop Works

Gestalt's design: detective examines first (accumulate tells), talks later (use evidence). The priority system supports this naturally:

  1. Default state: Talk = priority 0, Observe = priority 1. Player approaches NPC, sees "[E] Talk".
  2. After knowledge graph flags PersonOfInterest: has_investigation_context() returns true. Observe = priority 0, Talk (now labeled "Question") = priority 1. Player approaches flagged NPC, sees "[E] Observe".
  3. v0.1: Client shows only priority 0. The context switch happens automatically.
  4. v0.2: Client shows both. Player chooses.

The key insight: the context-sensitive switch IS the v0.2 multi-verb system operating with a filter. No throwaway code. v0.2 removes the filter; the rest works.

PlayerAction Extension

/// Semantic player actions — v3 protocol.
/// InteractPrimary is the v0.1 action (do whatever priority 0 says).
/// InteractWith is the v0.2 action (do a specific thing to a specific target).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerAction {
    // Movement (unchanged)
    MoveNorth,
    MoveSouth,
    MoveEast,
    MoveWest,
    MoveNortheast,
    MoveNorthwest,
    MoveSoutheast,
    MoveSouthwest,

    // Interaction (v3)
    /// Do the primary (priority 0) action on the nearest interactable target.
    /// v0.1 default. Maps to [E] key.
    InteractPrimary,
    /// Do a specific action on a specific target (v0.2+).
    /// Target is StableId. Action is the ActionKind.
    InteractWith { target_id: u64, action: ActionKind },
    /// Select a dialogue topic while in active conversation.
    DialogueSelect { topic: String },
    /// End the current dialogue.
    DialogueEnd,
    /// Acknowledge/advance examine result text.
    ExamineAdvance,

    // System (unchanged)
    UsePerceptionMode(String),
    Pause,
    Unpause,
}

Removed: the old Interact variant. InteractPrimary replaces it with the same behavior but clearer semantics. InteractWith is defined now but v0.1 client never emits it.

Server-Side Pipeline

Player presses [E]
  → Client sends InteractPrimary
  → Server: find nearest interactable entity within range
    → Compute AvailableActions for that entity
    → Execute priority-0 action:
      → ActionKind::Talk → enter dialogue state, run D-035 selection pipeline
      → ActionKind::ExamineNpc → emit tell observation event, queue examine result text
      → ActionKind::ExamineObject → emit examine event, queue description text
    → Pack results into ObserverSnapshot v3
  → Client renders result (dialogue panel, examine text, monologue)

Task 3: RON Converter

Architecture Decision

Dual-format loader with optional build-time compilation.

The server content loader supports both YAML and RON via serde. The format is determined by file extension. Content authors write YAML (human-friendly). An optional build step compiles YAML → RON for faster load times.

Where It Lives

server/src/content/
  loader.rs            # Content loader — reads YAML or RON based on extension
  schema.rs            # Rust serde structs (NpcProfile, DialogueLine, etc.)
  registry.rs          # ContentRegistry: canonical_id → StableId mapping
  validate.rs          # Semantic validation (cross-references, enum checks)

tooling/content-tools/
  Cargo.toml           # Small Rust binary
  src/
    main.rs            # CLI: content-tools validate|compile|stats
    compile.rs         # YAML → RON compiler
    validate.rs        # Schema validation against JSON Schema
    stats.rs           # Content statistics (line counts, coverage)

When It Runs

Context What Happens Format Loaded
Development (hot-reload) Server loads YAML directly. File watcher detects changes, reloads. YAML
CI / make content-validate content-tools validate runs JSON Schema + serde checks. No format conversion. YAML (validation only)
Build / make content-compile content-tools compile reads all YAML, writes RON to content/.cache/. YAML → RON
Production / release Server loads from content/.cache/*.ron if present, falls back to YAML. RON (preferred) or YAML

Implementation

The loader is format-agnostic. Approximately 20 lines handle the dual format:

use std::path::Path;

/// Load a content file, auto-detecting format from extension.
pub fn load_content<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, ContentError> {
    let bytes = std::fs::read(path)?;
    match path.extension().and_then(|e| e.to_str()) {
        Some("yaml" | "yml") => {
            serde_yaml::from_slice(&bytes).map_err(ContentError::Yaml)
        }
        Some("ron") => {
            ron::de::from_bytes(&bytes).map_err(ContentError::Ron)
        }
        _ => Err(ContentError::UnsupportedFormat(path.to_path_buf())),
    }
}

/// Load content with RON cache fallback.
/// Checks content/.cache/ for compiled RON first, falls back to YAML.
pub fn load_with_cache<T: serde::de::DeserializeOwned>(
    yaml_path: &Path,
    cache_dir: &Path,
) -> Result<T, ContentError> {
    // Try RON cache first
    let ron_path = cache_dir.join(
        yaml_path.with_extension("ron").file_name().unwrap()
    );
    if ron_path.exists() {
        return load_content(&ron_path);
    }
    // Fall back to YAML
    load_content(yaml_path)
}

Makefile Integration

content-validate:  ## Validate content files against schemas
	cargo run --manifest-path tooling/content-tools/Cargo.toml -- validate content/

content-compile:  ## Compile YAML content to RON cache
	cargo run --manifest-path tooling/content-tools/Cargo.toml -- compile content/ content/.cache/

content-stats:  ## Print content statistics
	cargo run --manifest-path tooling/content-tools/Cargo.toml -- stats content/

Why Not Build-Time Only?

Dudley builds with fixture data while copy authors real content. If compilation is required, Dudley can't test until copy delivers YAML AND it gets compiled. Direct YAML loading removes that gate. The RON cache is a performance optimization, not a correctness requirement.

The content/.cache/ directory is gitignored. It's local build output, not source content.


Task 4: ObserverSnapshot v2 → v3 Protocol Extension

Protocol Evolution Strategy

The snapshot already has a version: u8 field. The protocol evolves by:

  1. Incrementing the version number
  2. Adding new fields as Vec<T> (empty = not present) or Option<T>
  3. Never removing or reordering existing fields
  4. Client checks version, handles unknown fields gracefully

MessagePack (rmp-serde) handles this naturally — it serializes fields by name, so adding fields doesn't break existing clients. A v2 client reading a v3 snapshot ignores unknown fields. A v3 client reading a v2 snapshot gets empty defaults for new fields.

v3 Snapshot Definition

/// ObserverSnapshot v3 — extends v2 with interaction, dialogue, monologue, and speed data.
/// The ONLY data structure crossing the client-server boundary (D-020).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
    /// Protocol version. v3 = 3.
    pub version: u8,
    /// Simulation tick when this snapshot was produced.
    pub tick: u64,
    /// Game time data for client HUD display (D-031).
    pub game_time: GameTime,
    /// Player character's facing direction for vision cone (D-015).
    pub player_facing: FacingDirection,

    // --- v2 fields (unchanged) ---
    /// All entities visible to the observer (filtered by LOS + vision cone).
    pub entities: Vec<VisibleEntity>,
    /// Tiles visible to the observer for fog rendering.
    pub visible_tiles: Vec<VisibleTile>,

    // --- v3 additions ---

    /// Current simulation speed multiplier.
    /// 1.0 = normal, 0.5 = UI overlay slow-mo (D-PAUSE), 0.0 = paused.
    #[serde(default = "default_sim_speed")]
    pub sim_speed: f32,

    /// Entities within interaction range with available actions.
    /// Empty if no interactable targets nearby.
    #[serde(default)]
    pub nearby_interactions: Vec<NearbyInteraction>,

    /// Active dialogue state. None if not in conversation.
    #[serde(default)]
    pub active_dialogue: Option<ActiveDialogue>,

    /// Monologue lines triggered this tick.
    /// Usually 0-1 per tick. Multiple only if pacing allows it.
    #[serde(default)]
    pub monologue: Vec<MonologueDisplay>,

    /// Overheard conversation fragments (proximity-based, D-018).
    #[serde(default)]
    pub overheard: Vec<OverheardFragment>,

    /// Knowledge panel updates (delta, not full state).
    /// Only includes changes since last snapshot the client acknowledged.
    #[serde(default)]
    pub knowledge_updates: Vec<KnowledgeUpdate>,

    /// Examine result text (if player examined an entity/object this tick).
    #[serde(default)]
    pub examine_result: Option<ExamineResult>,

    /// News ticker headlines visible at current location.
    /// Empty if not near a Meridian display.
    #[serde(default)]
    pub ticker_headlines: Vec<TickerHeadline>,
}

fn default_sim_speed() -> f32 { 1.0 }

// --- v3 types ---

/// Interactive entity within range.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NearbyInteraction {
    /// StableId of the interactable entity.
    pub entity_id: u64,
    /// Display name as known to the observer.
    pub display_name: String,
    /// Available actions, sorted by priority (0 = primary).
    pub actions: Vec<ActionOption>,
}

/// Re-exported from interaction module for wire format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionOption {
    pub kind: ActionKind,
    pub label: String,
    pub priority: u8,
    pub enabled: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disabled_reason: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionKind {
    Talk,
    ExamineNpc,
    ExamineObject,
}

/// Active dialogue state sent to client each tick during conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveDialogue {
    /// StableId of the NPC in conversation.
    pub npc_id: u64,
    /// NPC display name.
    pub npc_name: String,
    /// Relationship color for the speaker name (D-033).
    pub relationship_state: RelationshipState,
    /// Current NPC line to display. None if waiting for player input.
    pub current_line: Option<DialogueLineDisplay>,
    /// Available topics the player can raise. Empty if no choices.
    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,
}

/// Monologue line for client display.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonologueDisplay {
    pub line_id: String,
    pub text: String,
    pub trigger: String,
    /// true for anomaly/contradiction observations → urgent chime (D-038).
    pub urgent: bool,
}

/// Overheard conversation fragment.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverheardFragment {
    /// StableId of the speaker (if known to observer).
    pub speaker_id: Option<u64>,
    /// Speaker name (if known). "Someone" if unknown.
    pub speaker_name: String,
    /// Fragment text — NOT the full line. What the listener could make out.
    pub fragment: String,
}

/// Knowledge panel delta update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeUpdate {
    pub update_type: KnowledgeUpdateType,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum KnowledgeUpdateType {
    /// Learned a new fact or upgraded confidence.
    FactLearned {
        fact_id: String,
        confidence: String,
        /// Human-readable description for the knowledge panel.
        display_text: String,
    },
    /// Learned something new about an entity.
    AttributeLearned {
        entity_id: u64,
        entity_name: String,
        key: String,
        value: String,
    },
    /// Relationship state changed.
    RelationshipChanged {
        entity_id: u64,
        entity_name: String,
        old_state: RelationshipState,
        new_state: RelationshipState,
    },
}

/// Result of examining an entity or object.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExamineResult {
    pub target_id: u64,
    pub target_name: String,
    /// Examine description text — what the character notices.
    pub text: String,
    /// Knowledge grants from the examination.
    pub knowledge_updates: Vec<KnowledgeUpdate>,
}

/// News ticker headline for Meridian display.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickerHeadline {
    pub headline_id: String,
    pub text: String,
    /// Does this headline trigger a monologue? Server handles trigger separately.
    pub triggers_monologue: bool,
}

Wire Size Estimate

Field Typical v3 Size Notes
v2 fields (entities, tiles) ~2-8 KB Already measured
sim_speed 4 bytes Float
nearby_interactions ~50-200 bytes 0-3 entities × 1-2 actions each
active_dialogue ~100-300 bytes Only during conversation
monologue ~0-200 bytes Usually 0-1 per tick
overheard ~0-150 bytes Rare
knowledge_updates ~0-200 bytes Delta, usually empty
examine_result ~0-300 bytes Only when examining
ticker_headlines ~0-400 bytes Only near Meridian display
v3 overhead ~50-1500 bytes On top of v2's 2-8 KB

Total v3 snapshot: ~2-10 KB per tick. At 10 tps, that's 20-100 KB/s over the IPC bridge. Well within budget — localhost MessagePack serialization handles this trivially. Even network (future multiplayer) is fine.

What Stig Needs to Know

New fields the client should handle in v3:

Field Client Action Priority
sim_speed Display pause indicator if 0.0; dim edges if 0.5 P0
nearby_interactions Show interaction prompt for actions[0] (priority 0 only in v0.1) P0
active_dialogue Show dialogue panel with NPC name, line text, topic options P0
monologue Show monologue text, fire chime (urgent flag → urgent chime) P0
overheard Show as faded text near speaker position (world-space) P1
knowledge_updates Update knowledge panel if open; flash minimap dot for new entities P1
examine_result Show examine text in monologue-style display (same panel, different color?) P0
ticker_headlines Render on world-space Meridian display entity P1

Client protocol handling:

if snapshot.version >= 3:
    process nearby_interactions → update interaction prompt
    process active_dialogue → update dialogue panel
    process monologue → queue monologue display
    process overheard → queue overheard display
    process knowledge_updates → update knowledge panel
    process examine_result → show examine text
    process ticker_headlines → update ticker display

v2 snapshots still work — all new fields have serde defaults (empty vecs, None optionals).


Summary

  1. Content schemas finalized. JSON Schema for NPC profiles, dialogue pools, monologue pools. The schema IS the contract between copy and server. Serde validates on load. Copy team can start writing YAML against these schemas immediately.

  2. Multi-verb architecture designed. AvailableActions per entity with priority-sorted ActionOption list. v0.1 client picks priority 0 (context-sensitive single action). v0.2 removes the filter and shows all options. No throwaway code — the full pipeline exists, only the client-side rendering is simplified for v0.1.

  3. RON converter = dual-format loader + optional build-time compilation. Loader supports both YAML and RON via serde. tooling/content-tools CLI provides validate, compile, stats. Development loads YAML directly (fast iteration). Production ships compiled RON in .cache/. ~20 lines of loader code, small CLI tool.

  4. ObserverSnapshot v3 defined. Adds interaction options, dialogue state, monologue lines, overheard fragments, knowledge deltas, examine results, ticker headlines, simulation speed. Wire overhead: ~50-1500 bytes per tick on top of v2. Backward compatible via serde defaults. Protocol version bumped to 3.

Everything here is additive and backward-compatible. The architecture supports v0.1's single-action simplicity while being ready for v0.2's multi-verb expansion. No throwaway.