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>
1155 lines
40 KiB
Markdown
1155 lines
40 KiB
Markdown
# 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
|
||
|
||
```yaml
|
||
# content/content.yaml
|
||
version: "0.1.0"
|
||
districts:
|
||
- id: "sova-transit"
|
||
path: "districts/sova-transit"
|
||
enabled: true
|
||
```
|
||
|
||
### Schema: NPC Profile (`npc-profile.schema.json`)
|
||
|
||
```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`)
|
||
|
||
```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`)
|
||
|
||
```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
|
||
|
||
```yaml
|
||
# 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
|
||
|
||
```yaml
|
||
# 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
|
||
|
||
```rust
|
||
/// 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:
|
||
|
||
```rust
|
||
/// 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
|
||
|
||
```rust
|
||
/// 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:
|
||
|
||
```rust
|
||
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
|
||
|
||
```makefile
|
||
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
|
||
|
||
```rust
|
||
/// 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.
|