feat(simulation): NpcBlueprint struct design, RON schema, and validator CLI (#611)

Define ZoneSpec, CultureProfile, and NpcBlueprint structs with serde/RON
deserialization. Ship example RON files as schema contract for the copy
team (#609, #610). Add validate-ron CLI for copy team to lint their files
without compiling the server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 21:39:27 +01:00
co-authored by Claude Opus 4.6
parent 4cbaf0cb57
commit fb3ebf4313
8 changed files with 638 additions and 1 deletions
+20 -1
View File
@@ -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",
+1
View File
@@ -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"
+84
View File
@@ -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 -- <file.ron> <zone|culture>
//! ```
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::<ZoneSpec>(&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::<CultureProfile>(&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);
}
}
}
+340
View File
@@ -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 <file.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<RoleSpec>,
/// Social site templates that can appear in this zone type.
pub social_sites: Vec<SocialSiteSpec>,
}
/// 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<String>,
/// 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<String>,
}
/// 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<String>,
/// 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<String>,
/// Pool of family names (may be empty if culture is first-name-primary).
pub family_names: Vec<String>,
/// 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<String>,
/// Common greetings.
pub greetings: Vec<String>,
/// Common farewells.
pub farewells: Vec<String>,
/// Oath or exclamation phrases.
pub exclamations: Vec<String>,
}
/// 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<PersonalityTrait>,
/// Traits that are less common in this culture (biased against).
pub disfavored_traits: Vec<PersonalityTrait>,
}
// ---------------------------------------------------------------------------
// 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<PersonalityTrait>,
/// Observable behaviors the player can witness.
pub observable_behaviors: Vec<String>,
/// Cultural markers derived from the culture profile.
pub cultural_markers: CulturalMarkers,
/// Relationship slots (0-3 per D-024).
pub relationships: Vec<BlueprintRelationship>,
}
/// 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<String>,
/// 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<NpcBlueprint>,
}
// ---------------------------------------------------------------------------
// 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);
}
}
+1
View File
@@ -4,6 +4,7 @@
pub mod awareness;
pub mod background;
pub mod blueprint;
pub mod disclosure;
pub mod generate;
pub mod interaction;