feat(simulation): add YAML to RON converter tool (#403)

Build-time converter reads content YAML and emits RON format. Lives in
tooling/content-converter/ as standalone Rust crate. Runs via make
content-ron. Supports --dry-run, --verbose, --content-type filtering.
Not on critical path — engine consumes YAML in v0.1, RON is future-
proofing for runtime performance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 01:19:40 +01:00
co-authored by Claude Opus 4.6
parent d3b68012d9
commit 67fef58020
6 changed files with 1104 additions and 1 deletions
+286
View File
@@ -0,0 +1,286 @@
//! content-converter: reads content YAML, emits RON.
//!
//! Build-time tool for converting authored YAML content files into RON (Rusty Object Notation)
//! for faster runtime deserialization. Not on v0.1 critical path — engine consumes YAML directly.
//! RON is future-proofing for runtime performance.
use clap::Parser;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
mod types;
use types::ContentFile;
#[derive(Parser)]
#[command(name = "content-converter", about = "Convert content YAML to RON")]
struct Cli {
/// Path to the content directory (default: content/)
#[arg(short, long, default_value = "content")]
input: PathBuf,
/// Path to write RON output (default: content-ron/)
#[arg(short, long, default_value = "content-ron")]
output: PathBuf,
/// Only convert files matching this content type
#[arg(short = 't', long)]
content_type: Option<ContentTypeFilter>,
/// Print what would be converted without writing files
#[arg(long)]
dry_run: bool,
/// Print verbose conversion details
#[arg(short, long)]
verbose: bool,
}
#[derive(Clone, Debug)]
enum ContentTypeFilter {
Npc,
Dialogue,
Monologue,
Triangle,
Location,
Routine,
District,
Faction,
}
impl std::str::FromStr for ContentTypeFilter {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"npc" => Ok(Self::Npc),
"dialogue" => Ok(Self::Dialogue),
"monologue" => Ok(Self::Monologue),
"triangle" => Ok(Self::Triangle),
"location" => Ok(Self::Location),
"routine" => Ok(Self::Routine),
"district" => Ok(Self::District),
"faction" => Ok(Self::Faction),
_ => Err(format!(
"Unknown content type: {s}. Valid: npc, dialogue, monologue, triangle, location, routine, district, faction"
)),
}
}
}
fn main() {
let cli = Cli::parse();
if !cli.input.exists() {
eprintln!(
"Error: content directory not found: {}",
cli.input.display()
);
std::process::exit(1);
}
let mut converted = 0u32;
let mut skipped = 0u32;
let mut errors = 0u32;
for entry in WalkDir::new(&cli.input)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.filter(|e| !is_schema_or_meta(e.path()))
{
let path = entry.path();
let content_type = classify_file(path);
if let Some(ref filter) = cli.content_type {
if !matches_filter(&content_type, filter) {
continue;
}
}
match convert_file(
path,
&cli.input,
&cli.output,
&content_type,
cli.dry_run,
cli.verbose,
) {
Ok(true) => converted += 1,
Ok(false) => skipped += 1,
Err(e) => {
eprintln!("Error converting {}: {e}", path.display());
errors += 1;
}
}
}
println!(
"Content conversion complete: {converted} converted, {skipped} skipped, {errors} errors"
);
if errors > 0 {
std::process::exit(1);
}
}
fn is_schema_or_meta(path: &Path) -> bool {
let path_str = path.to_string_lossy();
path_str.contains("_schema") || path_str.contains("_meta")
}
#[derive(Debug)]
enum ContentType {
NpcProfile,
DialoguePool,
MonologuePool,
Triangle,
Location,
Routine,
District,
Campaign,
System,
Station,
ContentManifest,
Faction,
Enum,
KnowledgeCatalog,
Unknown,
}
fn classify_file(path: &Path) -> ContentType {
let path_str = path.to_string_lossy();
if path_str.contains("/npcs/") {
ContentType::NpcProfile
} else if path_str.contains("/dialogue/") {
ContentType::DialoguePool
} else if path_str.contains("/monologue/") {
ContentType::MonologuePool
} else if path_str.contains("/triangles/") {
ContentType::Triangle
} else if path_str.contains("/locations/") {
ContentType::Location
} else if path_str.contains("/routines/") {
ContentType::Routine
} else if path_str.contains("/factions/") {
ContentType::Faction
} else if path_str.contains("/enums/") {
ContentType::Enum
} else if path_str.contains("/knowledge/") {
ContentType::KnowledgeCatalog
} else if path_str.ends_with("district.yaml") {
ContentType::District
} else if path_str.ends_with("station.yaml") {
ContentType::Station
} else if path_str.ends_with("system.yaml") {
ContentType::System
} else if path_str.ends_with("campaign.yaml") {
ContentType::Campaign
} else if path_str.ends_with("content.yaml") {
ContentType::ContentManifest
} else {
ContentType::Unknown
}
}
fn matches_filter(content_type: &ContentType, filter: &ContentTypeFilter) -> bool {
matches!(
(content_type, filter),
(ContentType::NpcProfile, ContentTypeFilter::Npc)
| (ContentType::DialoguePool, ContentTypeFilter::Dialogue)
| (ContentType::MonologuePool, ContentTypeFilter::Monologue)
| (ContentType::Triangle, ContentTypeFilter::Triangle)
| (ContentType::Location, ContentTypeFilter::Location)
| (ContentType::Routine, ContentTypeFilter::Routine)
| (ContentType::District, ContentTypeFilter::District)
| (ContentType::Faction, ContentTypeFilter::Faction)
)
}
fn convert_file(
path: &Path,
input_root: &Path,
output_root: &Path,
content_type: &ContentType,
dry_run: bool,
verbose: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let yaml_str = std::fs::read_to_string(path)?;
// Skip stub files (comment-only, no real YAML content)
let trimmed = yaml_str
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
if trimmed.is_empty() {
if verbose {
println!(" skip (stub): {}", path.display());
}
return Ok(false);
}
let content_file = parse_yaml(&trimmed, content_type)?;
let ron_config = ron::ser::PrettyConfig::default()
.struct_names(true)
.enumerate_arrays(false);
let ron_str = ron::ser::to_string_pretty(&content_file, ron_config)?;
// Compute output path: replace input root with output root, .yaml -> .ron
let rel_path = path.strip_prefix(input_root)?;
let mut out_path = output_root.join(rel_path);
out_path.set_extension("ron");
if dry_run {
println!(
" would convert: {} -> {}",
path.display(),
out_path.display()
);
return Ok(true);
}
if verbose {
println!(" convert: {} -> {}", path.display(), out_path.display());
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, ron_str)?;
Ok(true)
}
fn parse_yaml(
yaml_str: &str,
content_type: &ContentType,
) -> Result<ContentFile, Box<dyn std::error::Error>> {
let file = match content_type {
ContentType::NpcProfile => {
ContentFile::NpcProfile(Box::new(serde_yaml::from_str(yaml_str)?))
}
ContentType::DialoguePool => ContentFile::DialoguePool(serde_yaml::from_str(yaml_str)?),
ContentType::MonologuePool => ContentFile::MonologuePool(serde_yaml::from_str(yaml_str)?),
ContentType::Triangle => ContentFile::Triangle(serde_yaml::from_str(yaml_str)?),
ContentType::Location => ContentFile::Location(serde_yaml::from_str(yaml_str)?),
ContentType::Routine => ContentFile::Routine(serde_yaml::from_str(yaml_str)?),
ContentType::District => ContentFile::District(serde_yaml::from_str(yaml_str)?),
ContentType::Campaign => ContentFile::Campaign(serde_yaml::from_str(yaml_str)?),
ContentType::System | ContentType::Station => {
ContentFile::Metadata(serde_yaml::from_str(yaml_str)?)
}
ContentType::ContentManifest => ContentFile::Manifest(serde_yaml::from_str(yaml_str)?),
ContentType::Faction => ContentFile::Generic(serde_yaml::from_str(yaml_str)?),
ContentType::Enum | ContentType::KnowledgeCatalog => {
ContentFile::Generic(serde_yaml::from_str(yaml_str)?)
}
ContentType::Unknown => ContentFile::Generic(serde_yaml::from_str(yaml_str)?),
};
Ok(file)
}
+443
View File
@@ -0,0 +1,443 @@
//! Content types mirroring the JSON Schema definitions in content/_schema/.
//!
//! These types are used for YAML deserialization and RON serialization.
//! They do NOT need to match the server's ECS component types — this is a
//! build-time conversion tool, not runtime code.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Top-level enum wrapping all content file types for RON output.
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ContentFile {
NpcProfile(Box<NpcProfile>),
DialoguePool(DialoguePool),
MonologuePool(MonologuePool),
Triangle(Triangle),
Location(Location),
Routine(RoutineFile),
District(District),
Campaign(Campaign),
Manifest(ContentManifest),
Metadata(GenericMetadata),
Generic(serde_yaml::Value),
}
// --- NPC Profile (npc-profile.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct NpcProfile {
pub canonical_id: String,
pub display_name: String,
pub tier: u8,
pub pattern: String,
pub motivation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub want: Option<Want>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tolerance: Option<Tolerance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routine: Option<RoutineSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub information: Option<Information>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contentment: Option<Contentment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tells: Vec<Tell>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<Skills>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub triangle_membership: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust_levels: Option<TrustLevels>,
#[serde(skip_serializing_if = "Option::is_none")]
pub friend_arc: Option<FriendArc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dual_lens: Option<DualLens>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Want {
pub primary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub intensity: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Relationship {
pub target: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Tolerance {
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Information {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub knows: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_tier: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Contentment {
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Tell {
pub trigger: String,
pub behavior: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Skills {
#[serde(skip_serializing_if = "Option::is_none")]
pub combat_trained: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<BTreeMap<String, i32>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TrustLevels {
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub real: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendArc {
pub bonded_character: String,
pub phases: Vec<FriendPhase>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendPhase {
pub phase: u8,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routine_deviation: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DualLens {
#[serde(skip_serializing_if = "Option::is_none")]
pub smuggler: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detective: Option<String>,
}
// --- Dialogue Pool (dialogue-pool.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct DialoguePool {
pub location: String,
pub role: String,
pub lines: Vec<DialogueLine>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DialogueLine {
pub id: String,
pub text: String,
pub role: String,
pub access: Vec<String>,
pub trust: String,
pub situation: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topic: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mood: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KnowledgeGrant {
pub fact_id: String,
pub confidence: String,
}
// --- Monologue Pool (monologue-pool.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct MonologuePool {
pub character: String,
pub location: String,
pub lines: Vec<MonologueLine>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MonologueLine {
pub id: String,
pub text: String,
pub trigger: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prerequisites: Option<Prerequisites>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooldown: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Prerequisites {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub facts: Vec<FactPrerequisite>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entity_attributes: Vec<AttributePrerequisite>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relationship: Option<RelationshipPrerequisite>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FactPrerequisite {
pub fact_id: String,
pub min_confidence: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttributePrerequisite {
pub entity: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RelationshipPrerequisite {
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
// --- Triangle (triangle.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Triangle {
pub canonical_id: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub members: Vec<TriangleMember>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forks: Vec<Fork>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resolution_states: Vec<Resolution>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TriangleMember {
pub npc: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Fork {
pub id: String,
pub condition: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outcomes: Vec<ForkOutcome>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForkOutcome {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Resolution {
pub id: String,
pub description: String,
}
// --- Location (location.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Location {
pub canonical_id: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile_bounds: Option<TileBounds>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sightlines: Option<Sightlines>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ambient_sound: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub social_site: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TileBounds {
pub x_min: i32,
pub y_min: i32,
pub x_max: i32,
pub y_max: i32,
pub z: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Sightlines {
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
// --- Routine (routine.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineFile {
pub district: String,
pub schedules: Vec<NpcSchedule>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NpcSchedule {
pub npc: String,
pub entries: Vec<RoutineEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deviations: Vec<Deviation>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoutineEntry {
pub phase: String,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<TileCoord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Deviation {
pub trigger: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<TileCoord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TileCoord {
pub x: i32,
pub y: i32,
}
// --- District (district.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct District {
pub display_name: String,
pub description: String,
pub locations: Vec<String>,
pub npc_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub station: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub district: Option<String>,
}
// --- Campaign (campaign.schema.json) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct Campaign {
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
// --- Content manifest (content.yaml) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct ContentManifest {
pub version: String,
pub campaigns: Vec<CampaignRef>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CampaignRef {
pub id: String,
pub path: String,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovery: Option<Discovery>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Discovery {
#[serde(skip_serializing_if = "Option::is_none")]
pub districts: Option<String>,
}
// --- Generic metadata (system.yaml, station.yaml) ---
#[derive(Debug, Serialize, Deserialize)]
pub struct GenericMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}