diff --git a/.gitignore b/.gitignore index 95c78f8aa..0adb1fb12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build and cache .cache/ server/target/ +tooling/content-converter/target/ +tooling/line-previewer/target/ +content-ron/ # Godot client (further ignores managed by client team) client/.godot/ diff --git a/Makefile b/Makefile index 9f8ac26a8..eaec20fcf 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server test lint ci ci-client ci-server clean \ decisions-sync decisions-coverage decisions-active decisions-orphan \ - db-backup db-install validate-content + db-backup db-install validate-content content-ron # --- Configuration --- @@ -31,6 +31,7 @@ help: @echo " make decisions-active List active decisions" @echo " make decisions-orphan Decisions without implementing tickets" @echo " make validate-content Validate content YAML against schemas" + @echo " make content-ron Convert content YAML to RON (build-time)" @echo "" @echo " GODOT_VERSION=4.6 make setup Override Godot version" @@ -133,6 +134,10 @@ decisions-orphan: validate-content: @tooling/validate-content +content-ron: + cd tooling/content-converter && cargo build --release + tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose + # --- Clean --- clean: diff --git a/tooling/content-converter/Cargo.lock b/tooling/content-converter/Cargo.lock new file mode 100644 index 000000000..c171c5ad5 --- /dev/null +++ b/tooling/content-converter/Cargo.lock @@ -0,0 +1,349 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "content-converter" +version = "0.1.0" +dependencies = [ + "clap", + "ron", + "serde", + "serde_yaml", + "thiserror", + "walkdir", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[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 = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tooling/content-converter/Cargo.toml b/tooling/content-converter/Cargo.toml new file mode 100644 index 000000000..49a4104a4 --- /dev/null +++ b/tooling/content-converter/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "content-converter" +version = "0.1.0" +edition = "2021" +description = "Build-time converter: reads content YAML, emits RON for faster runtime deserialization." + +[[bin]] +name = "content-converter" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +ron = "0.8" +clap = { version = "4", features = ["derive"] } +walkdir = "2" +thiserror = "2" diff --git a/tooling/content-converter/src/main.rs b/tooling/content-converter/src/main.rs new file mode 100644 index 000000000..a685fd214 --- /dev/null +++ b/tooling/content-converter/src/main.rs @@ -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, + + /// 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 { + 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> { + 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::>() + .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> { + 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) +} diff --git a/tooling/content-converter/src/types.rs b/tooling/content-converter/src/types.rs new file mode 100644 index 000000000..bc3456fa1 --- /dev/null +++ b/tooling/content-converter/src/types.rs @@ -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), + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub want: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relationships: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tolerance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub routine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub information: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contentment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tells: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub triangle_membership: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_levels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub friend_arc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dual_lens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Want { + pub primary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub intensity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Relationship { + pub target: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub trust: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tolerance { + #[serde(skip_serializing_if = "Option::is_none")] + pub threshold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Information { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub knows: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_tier: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Contentment { + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tell { + pub trigger: String, + pub behavior: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub visible_to: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Skills { + #[serde(skip_serializing_if = "Option::is_none")] + pub combat_trained: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TrustLevels { + #[serde(skip_serializing_if = "Option::is_none")] + pub surface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub real: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FriendArc { + pub bonded_character: String, + pub phases: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FriendPhase { + pub phase: u8, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub routine_deviation: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DualLens { + #[serde(skip_serializing_if = "Option::is_none")] + pub smuggler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detective: Option, +} + +// --- Dialogue Pool (dialogue-pool.schema.json) --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct DialoguePool { + pub location: String, + pub role: String, + pub lines: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DialogueLine { + pub id: String, + pub text: String, + pub role: String, + pub access: Vec, + pub trust: String, + pub situation: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub topic: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mood: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub knowledge_grant: Option, +} + +#[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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cooldown: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Prerequisites { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub facts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entity_attributes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub relationship: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, +} + +// --- 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, + pub members: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub forks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resolution_states: Vec, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ForkOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub tile_bounds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sightlines: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ambient_sound: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub social_site: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +// --- Routine (routine.schema.json) --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineFile { + pub district: String, + pub schedules: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NpcSchedule { + pub npc: String, + pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deviations: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineEntry { + pub phase: String, + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub activity: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Deviation { + pub trigger: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub activity: Option, +} + +#[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, + pub npc_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub canonical_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub station: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub district: Option, +} + +// --- 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +// --- Content manifest (content.yaml) --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct ContentManifest { + pub version: String, + pub campaigns: Vec, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Discovery { + #[serde(skip_serializing_if = "Option::is_none")] + pub districts: Option, +} + +// --- Generic metadata (system.yaml, station.yaml) --- + +#[derive(Debug, Serialize, Deserialize)] +pub struct GenericMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(flatten)] + pub extra: BTreeMap, +}