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:
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+349
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user