diff --git a/content/global/culture-krenn.example.ron b/content/global/culture-krenn.example.ron new file mode 100644 index 000000000..b7607ad2c --- /dev/null +++ b/content/global/culture-krenn.example.ron @@ -0,0 +1,67 @@ +// Krenn Culture Profile — example file +// +// Schema: server/src/npc/blueprint.rs :: CultureProfile +// Real content: ticket #610 (copy team fills this) +// Validate: tooling/validate-ron content/global/culture-krenn.example.ron culture +// +// One file per culture. The generator reads this to give NPCs culturally +// appropriate names, speech patterns, and personality bias. +// +// Source: D-036 (Krenn System canonical setting), D-128 (culture implicit in location), +// D-121 (voice culture-driven), D-123 (AI content templating via culture vectors). + +( + id: "krenn", + name: "Krenn System Culture", + description: "Working-class pragmatic culture. ~180 years settled, mid-Reach G3V system. Community-oriented, suspicious of distant authority, values competence and reliability over credentials. First-name-primary in social contexts.", + + naming: ( + style: "compact, consonant-heavy, first-name-primary in social contexts", + given_names: [ + "Kael", "Voss", "Lera", "Torek", "Drin", + "Maret", "Naia", "Sera", "Nils", "Pael", + "Tev", "Ren", "Sess", "Renn", "Olin", + "Tav", "Resha", "Harek", "Sabel", "Pell", + ], + family_names: [ + "Davan", "Sessik", "Korr", "Tamm", "Venn", + "Lintar", "Darvo", "Kosse", + ], + // Krenn culture is first-name-primary. Family names exist but are + // used mainly in formal/institutional contexts. + family_name_used_socially: false, + ), + + speech: ( + register: "direct, minimal pleasantries, gets to the point", + filler_words: [ + "look", + "right", + "yeah", + "so", + ], + greetings: [ + "hey", + "morning", + "shift treating you alright?", + ], + farewells: [ + "shift's calling", + "gotta move", + "catch you later", + ], + exclamations: [ + "void take it", + "stars", + "unbelievable", + ], + ), + + values: ( + description: "Pragmatic, community-oriented, suspicious of authority. Competence earns respect. Showing up and doing the work matters more than rank or credentials. Outsiders are tolerated but watched.", + // Traits more common in Krenn culture — generator biases toward these. + favored_traits: [Bold, Honest, Curious], + // Traits less common — generator biases away from these. + disfavored_traits: [Reclusive, Deceptive], + ), +) diff --git a/content/global/zone-identity-spec.example.ron b/content/global/zone-identity-spec.example.ron new file mode 100644 index 000000000..05061c17a --- /dev/null +++ b/content/global/zone-identity-spec.example.ron @@ -0,0 +1,97 @@ +// Zone Identity Spec — example file +// +// Schema: server/src/npc/blueprint.rs :: ZoneSpec +// Real content: ticket #609 (copy team fills this) +// Validate: tooling/validate-ron content/global/zone-identity-spec.example.ron zone +// +// One file per zone type. The generator reads this to decide NPC count, +// role distribution, and social site placement. +// +// Format: RON (Rusty Object Notation) — the Rust structs ARE the schema. +// Comments are allowed. Trailing commas are allowed. + +( + zone_type: "rural", + label: "Rural Settlement", + description: "Scattered homesteads, small workshops, and communal gathering spots. Low population density, strong community bonds, subsistence-plus economy.", + + // 1-10 scale. Rural = low economic activity, mostly self-sufficient. + economic_level: 3, + // 1-10 scale. Rural = sparse, everyone knows everyone. + population_density: 2, + + roles: [ + ( + id: "farmer", + label: "Farmer", + weight: 5, + skill_focus: ["technical"], + combat_eligible: false, + typical_behaviors: [ + "tends crops in the field", + "hauls produce to the market stall", + "repairs equipment by hand", + ], + ), + ( + id: "mechanic", + label: "Settlement Mechanic", + weight: 3, + skill_focus: ["technical"], + combat_eligible: false, + typical_behaviors: [ + "works on machinery with focused intensity", + "wipes grease on coveralls between tasks", + "explains repairs in terse technical shorthand", + ], + ), + ( + id: "trader", + label: "Itinerant Trader", + weight: 2, + skill_focus: ["persuasion", "observation"], + combat_eligible: false, + typical_behaviors: [ + "arranges goods on a portable display", + "haggles with quiet persistence", + "watches foot traffic from market stall", + ], + ), + ( + id: "militia", + label: "Settlement Militia", + weight: 1, + skill_focus: ["combat", "observation"], + combat_eligible: true, + typical_behaviors: [ + "patrols the settlement perimeter", + "checks credentials at the gate", + "leans on rifle while scanning the horizon", + ], + ), + ], + + social_sites: [ + ( + site_type: "tavern", + label: "Local Tavern", + roles: ["farmer", "mechanic", "trader", "militia"], + min_npcs: 3, + max_npcs: 6, + ), + ( + site_type: "workshop", + label: "Community Workshop", + roles: ["mechanic", "farmer"], + min_npcs: 2, + max_npcs: 4, + ), + ( + site_type: "market_stall", + label: "Market Stall", + roles: ["trader", "farmer"], + min_npcs: 1, + max_npcs: 3, + ), + ], +) diff --git a/server/Cargo.lock b/server/Cargo.lock index 5ea6fa21a..1b66e7a5a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -128,6 +128,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bevy_app" version = "0.18.0" @@ -1001,6 +1007,18 @@ dependencies = [ "serde", ] +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64", + "bitflags", + "serde", + "serde_derive", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1092,7 +1110,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.23" +version = "0.1.24" dependencies = [ "bevy_app", "bevy_ecs", @@ -1102,6 +1120,7 @@ dependencies = [ "rand", "rand_chacha", "rmp-serde", + "ron", "serde", "serde_json", "serde_yaml", diff --git a/server/Cargo.toml b/server/Cargo.toml index 76cb7b1ab..2dd479e05 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,6 +8,7 @@ bevy_ecs = "0.18" bevy_app = "0.18" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" +ron = "0.8" rmp-serde = "1" bincode = "1" rand = "0.9" diff --git a/server/src/bin/validate_ron.rs b/server/src/bin/validate_ron.rs new file mode 100644 index 000000000..51356a159 --- /dev/null +++ b/server/src/bin/validate_ron.rs @@ -0,0 +1,84 @@ +//! RON content validator CLI (#611). +//! +//! Deserializes a RON file into the actual Rust structs and prints errors. +//! This is the copy team's lint tool — run it to check RON files without +//! needing to compile the full server. +//! +//! # Usage +//! +//! ```sh +//! # Via wrapper script (recommended): +//! tooling/validate-ron content/global/zone-identity-spec.example.ron zone +//! tooling/validate-ron content/global/culture-krenn.example.ron culture +//! +//! # Direct: +//! cargo run --bin validate_ron -- +//! ``` + +use std::process; + +use clap::Parser; + +use settled_reach_server::npc::blueprint::{CultureProfile, ZoneSpec}; + +#[derive(Parser)] +#[command( + name = "validate_ron", + about = "Validate RON content files against Rust struct schemas" +)] +struct Args { + /// Path to the RON file to validate. + file: String, + /// Schema type: "zone" (ZoneSpec) or "culture" (CultureProfile). + schema: String, +} + +fn main() { + let args = Args::parse(); + + let content = match std::fs::read_to_string(&args.file) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading {}: {}", args.file, e); + process::exit(1); + } + }; + + match args.schema.as_str() { + "zone" => match ron::from_str::(&content) { + Ok(spec) => { + println!("Valid ZoneSpec: {} ({})", spec.label, spec.zone_type); + println!(" {} roles, {} social sites", spec.roles.len(), spec.social_sites.len()); + } + Err(e) => { + eprintln!("Invalid ZoneSpec in {}:", args.file); + eprintln!(" {}", e); + process::exit(1); + } + }, + "culture" => match ron::from_str::(&content) { + Ok(profile) => { + println!("Valid CultureProfile: {} ({})", profile.name, profile.id); + println!( + " {} given names, {} family names", + profile.naming.given_names.len(), + profile.naming.family_names.len() + ); + println!( + " {} filler words, {} favored traits", + profile.speech.filler_words.len(), + profile.values.favored_traits.len() + ); + } + Err(e) => { + eprintln!("Invalid CultureProfile in {}:", args.file); + eprintln!(" {}", e); + process::exit(1); + } + }, + other => { + eprintln!("Unknown schema type: '{}'. Use 'zone' or 'culture'.", other); + process::exit(1); + } + } +} diff --git a/server/src/npc/blueprint.rs b/server/src/npc/blueprint.rs new file mode 100644 index 000000000..3f26d4dea --- /dev/null +++ b/server/src/npc/blueprint.rs @@ -0,0 +1,340 @@ +//! NPC blueprint structs for the generator spike (#611). +//! +//! Three input/output structs for the generator proof-of-life: +//! - `ZoneSpec` — zone identity specification (deserializes from zone-identity-spec RON) +//! - `CultureProfile` — culture profile (deserializes from culture RON) +//! - `NpcBlueprint` — generator output for a single NPC +//! +//! Plus `SpikeOutput` as the top-level binary output container. +//! These are spike-specific and intentionally decoupled from `DistrictSkeleton`. +//! +//! ## Content contract +//! +//! The Rust structs ARE the schema. Copy team fills RON files to match these structs: +//! - Zone identity spec: ticket #609 +//! - Culture profile (Krenn): ticket #610 +//! +//! ## Format +//! +//! RON (Rusty Object Notation) — struct-aware, supports enums and comments. +//! Validate with: `tooling/validate-ron ` + +use serde::{Deserialize, Serialize}; + +use crate::npc::PersonalityTrait; + +// --------------------------------------------------------------------------- +// Zone identity specification (input — filled by copy team, ticket #609) +// --------------------------------------------------------------------------- + +/// Top-level zone identity spec. One file per zone type. +/// +/// Describes what a zone IS — its purpose, population shape, and social sites. +/// The generator reads this to decide how many NPCs to create, what roles they +/// fill, and what social sites exist. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneSpec { + /// Zone type identifier (e.g. "rural", "industrial", "commercial"). + pub zone_type: String, + /// Human-readable label for output headers. + pub label: String, + /// Short description of this zone type's character. + pub description: String, + /// Economic activity level (1-10). Affects NPC count and role distribution. + pub economic_level: u8, + /// Population density hint (1-10). Generator scales NPC count from this. + pub population_density: u8, + /// Role definitions available in this zone type. + /// Each role has a weight (relative frequency) and properties. + pub roles: Vec, + /// Social site templates that can appear in this zone type. + pub social_sites: Vec, +} + +/// A role that NPCs can fill in a zone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleSpec { + /// Role identifier (e.g. "dock_worker", "merchant", "guard"). + pub id: String, + /// Human-readable label. + pub label: String, + /// Relative weight for role selection (higher = more common). + pub weight: u8, + /// Skills biased toward for this role. + pub skill_focus: Vec, + /// Whether this role may have combat capability. + pub combat_eligible: bool, + /// Observable behaviors typical for this role (generator picks from these). + pub typical_behaviors: Vec, +} + +/// A social site template within a zone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SocialSiteSpec { + /// Site type identifier (e.g. "bar", "workshop", "market_stall"). + pub site_type: String, + /// Human-readable label. + pub label: String, + /// Roles that staff or frequent this site (references RoleSpec.id). + pub roles: Vec, + /// Minimum NPCs associated with this site. + pub min_npcs: u8, + /// Maximum NPCs associated with this site. + pub max_npcs: u8, +} + +// --------------------------------------------------------------------------- +// Culture profile (input — filled by copy team, ticket #610) +// --------------------------------------------------------------------------- + +/// Culture profile for a star system or region. +/// +/// Drives the cultural texture of generated NPCs: how they speak, what names +/// they have, what values shape their personality distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CultureProfile { + /// Culture identifier (e.g. "krenn"). + pub id: String, + /// Human-readable culture name. + pub name: String, + /// Short cultural description for generator context. + pub description: String, + /// Naming conventions for this culture. + pub naming: NamingConventions, + /// Speech patterns — how NPCs from this culture talk. + pub speech: SpeechPatterns, + /// Cultural values that bias personality trait selection. + pub values: CulturalValues, +} + +/// Naming conventions for NPC name generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamingConventions { + /// Style description (e.g. "compact, consonant-heavy, first-name-primary"). + pub style: String, + /// Pool of given names the generator draws from. + pub given_names: Vec, + /// Pool of family names (may be empty if culture is first-name-primary). + pub family_names: Vec, + /// Whether family names are commonly used in social contexts. + pub family_name_used_socially: bool, +} + +/// Speech patterns that color NPC dialogue and observable behaviors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpeechPatterns { + /// Speech register description (e.g. "direct, minimal pleasantries"). + pub register: String, + /// Filler words and verbal tics drawn from this culture. + pub filler_words: Vec, + /// Common greetings. + pub greetings: Vec, + /// Common farewells. + pub farewells: Vec, + /// Oath or exclamation phrases. + pub exclamations: Vec, +} + +/// Cultural values that influence personality trait distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CulturalValues { + /// Brief description of the culture's value system. + pub description: String, + /// Traits that are more common in this culture (biased toward). + pub favored_traits: Vec, + /// Traits that are less common in this culture (biased against). + pub disfavored_traits: Vec, +} + +// --------------------------------------------------------------------------- +// NPC blueprint (output — generator produces these) +// --------------------------------------------------------------------------- + +/// Generator output for a single NPC. +/// +/// Maps to the 10-axis model (D-024) but as a serializable data record, +/// not ECS components. The spike binary prints these; the full pipeline +/// will convert `NpcBlueprint` → `RoleDefinition` → ECS entity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpcBlueprint { + /// Generated NPC name. + pub name: String, + /// Role identifier (matches RoleSpec.id from the zone spec). + pub role: String, + /// Personality traits (2-3, no contradictory pairs). + pub traits: Vec, + /// Observable behaviors the player can witness. + pub observable_behaviors: Vec, + /// Cultural markers derived from the culture profile. + pub cultural_markers: CulturalMarkers, + /// Relationship slots (0-3 per D-024). + pub relationships: Vec, +} + +/// Cultural markers attached to a generated NPC. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CulturalMarkers { + /// Speech register inherited from culture. + pub speech_register: String, + /// Filler words this NPC uses (subset of culture's pool). + pub filler_words: Vec, + /// Greeting this NPC tends to use. + pub greeting: String, +} + +/// A relationship in the blueprint (pre-ECS, uses string IDs). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlueprintRelationship { + /// Target NPC name (resolved to StableId at spawn time). + pub target_name: String, + /// Relationship type. + pub relationship_type: String, + /// Valence: positive, negative, or neutral. + pub valence: RelationshipValence, +} + +/// Relationship valence for blueprint output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RelationshipValence { + Positive, + Negative, + Neutral, +} + +// --------------------------------------------------------------------------- +// Spike output container +// --------------------------------------------------------------------------- + +/// Top-level output for the generator spike binary. +/// +/// Intentionally decoupled from `DistrictSkeleton` — this is a proof-of-life +/// container, not production architecture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpikeOutput { + /// Zone type that was generated. + pub zone_type: String, + /// Seed used for generation. + pub seed: u64, + /// Culture profile used. + pub culture: String, + /// Generated NPCs. + pub npcs: Vec, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zone_spec_round_trips_through_ron() { + let spec = ZoneSpec { + zone_type: "rural".into(), + label: "Rural Settlement".into(), + description: "Scattered homesteads and small workshops".into(), + economic_level: 3, + population_density: 2, + roles: vec![RoleSpec { + id: "farmer".into(), + label: "Farmer".into(), + weight: 5, + skill_focus: vec!["technical".into()], + combat_eligible: false, + typical_behaviors: vec!["tends crops".into()], + }], + social_sites: vec![SocialSiteSpec { + site_type: "tavern".into(), + label: "Local Tavern".into(), + roles: vec!["farmer".into()], + min_npcs: 2, + max_npcs: 5, + }], + }; + + let ron_str = ron::ser::to_string_pretty(&spec, ron::ser::PrettyConfig::default()).unwrap(); + let deserialized: ZoneSpec = ron::from_str(&ron_str).unwrap(); + assert_eq!(deserialized.zone_type, "rural"); + assert_eq!(deserialized.roles.len(), 1); + assert_eq!(deserialized.social_sites.len(), 1); + } + + #[test] + fn culture_profile_round_trips_through_ron() { + let profile = CultureProfile { + id: "krenn".into(), + name: "Krenn System Culture".into(), + description: "Working-class pragmatic culture".into(), + naming: NamingConventions { + style: "compact, consonant-heavy".into(), + given_names: vec!["Kael".into(), "Voss".into()], + family_names: vec!["Davan".into()], + family_name_used_socially: false, + }, + speech: SpeechPatterns { + register: "direct, minimal pleasantries".into(), + filler_words: vec!["look".into(), "right".into()], + greetings: vec!["hey".into()], + farewells: vec!["shift's calling".into()], + exclamations: vec!["void take it".into()], + }, + values: CulturalValues { + description: "Pragmatic, community-oriented, suspicious of authority".into(), + favored_traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest], + disfavored_traits: vec![PersonalityTrait::Reclusive], + }, + }; + + let ron_str = + ron::ser::to_string_pretty(&profile, ron::ser::PrettyConfig::default()).unwrap(); + let deserialized: CultureProfile = ron::from_str(&ron_str).unwrap(); + assert_eq!(deserialized.id, "krenn"); + assert_eq!(deserialized.naming.given_names.len(), 2); + assert_eq!(deserialized.values.favored_traits.len(), 2); + } + + #[test] + fn npc_blueprint_round_trips_through_ron() { + let blueprint = NpcBlueprint { + name: "Kael".into(), + role: "dock_worker".into(), + traits: vec![PersonalityTrait::Bold, PersonalityTrait::Honest], + observable_behaviors: vec!["works efficiently".into()], + cultural_markers: CulturalMarkers { + speech_register: "direct".into(), + filler_words: vec!["look".into()], + greeting: "hey".into(), + }, + relationships: vec![BlueprintRelationship { + target_name: "Voss".into(), + relationship_type: "colleague".into(), + valence: RelationshipValence::Positive, + }], + }; + + let ron_str = + ron::ser::to_string_pretty(&blueprint, ron::ser::PrettyConfig::default()).unwrap(); + let deserialized: NpcBlueprint = ron::from_str(&ron_str).unwrap(); + assert_eq!(deserialized.name, "Kael"); + assert_eq!(deserialized.traits.len(), 2); + assert_eq!(deserialized.relationships.len(), 1); + } + + #[test] + fn spike_output_round_trips_through_ron() { + let output = SpikeOutput { + zone_type: "rural".into(), + seed: 42, + culture: "krenn".into(), + npcs: vec![], + }; + + let ron_str = + ron::ser::to_string_pretty(&output, ron::ser::PrettyConfig::default()).unwrap(); + let deserialized: SpikeOutput = ron::from_str(&ron_str).unwrap(); + assert_eq!(deserialized.zone_type, "rural"); + assert_eq!(deserialized.seed, 42); + } +} diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 9bfa565d4..5428dbcac 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -4,6 +4,7 @@ pub mod awareness; pub mod background; +pub mod blueprint; pub mod disclosure; pub mod generate; pub mod interaction; diff --git a/tooling/validate-ron b/tooling/validate-ron new file mode 100755 index 000000000..175971fe2 --- /dev/null +++ b/tooling/validate-ron @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# RON content validator — wrapper for the Rust validate_ron binary (#611). +# +# Usage: +# tooling/validate-ron +# +# Examples: +# tooling/validate-ron content/global/zone-identity-spec.example.ron zone +# tooling/validate-ron content/global/culture-krenn.example.ron culture + +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: tooling/validate-ron " + echo "" + echo "Validates a RON file against the Rust struct schema." + echo "Schema types:" + echo " zone — ZoneSpec (zone identity spec)" + echo " culture — CultureProfile (culture profile)" + exit 1 +fi + +# Resolve file to absolute path before changing directory +FILE="$(realpath "$1")" +SCHEMA="$2" + +cd "$(dirname "$0")/../server" +exec cargo run --quiet --bin validate_ron -- "$FILE" "$SCHEMA"