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>
40 KiB
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:
- Default state: Talk = priority 0, Observe = priority 1. Player approaches NPC, sees "[E] Talk".
- 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". - v0.1: Client shows only priority 0. The context switch happens automatically.
- 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:
- Incrementing the version number
- Adding new fields as
Vec<T>(empty = not present) orOption<T> - Never removing or reordering existing fields
- 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
-
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.
-
Multi-verb architecture designed.
AvailableActionsper entity with priority-sortedActionOptionlist. 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. -
RON converter = dual-format loader + optional build-time compilation. Loader supports both YAML and RON via serde.
tooling/content-toolsCLI providesvalidate,compile,stats. Development loads YAML directly (fast iteration). Production ships compiled RON in.cache/. ~20 lines of loader code, small CLI tool. -
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.